From 81dbf9a385f6093aed62323a68608c223717cd03 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 21 Jul 2026 09:53:33 +0200 Subject: [PATCH 001/295] Fix empty spot-plot and resolution percentile in SpotAnalyze GenerateSpotPlot iterated msg.spots, but SpotAnalyze called it before assigning output.spots. In the online path the DataMessage is fresh per frame, so the plot was built from an empty list and spot_plot_intensity / spot_plot_count came out all zeros. Pass the finished spots vector explicitly instead of relying on the field being set: the live path passes the full pre-truncation list, the HDF5 read-back path passes message.spots. GetResolution scaled the 5th-percentile index by spots.size() (which includes ice-ring spots) while indexing the ice-filtered resolutions vector, biasing the estimate and reading out of bounds on ice-heavy frames. Index by resolutions.size() instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- image_analysis/spot_finding/SpotUtils.cpp | 9 ++++----- image_analysis/spot_finding/SpotUtils.h | 2 +- reader/HDF5MetadataSource.cpp | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/image_analysis/spot_finding/SpotUtils.cpp b/image_analysis/spot_finding/SpotUtils.cpp index 8dcf3254..d272fb3f 100644 --- a/image_analysis/spot_finding/SpotUtils.cpp +++ b/image_analysis/spot_finding/SpotUtils.cpp @@ -94,17 +94,17 @@ std::optional GetResolution(const std::vector &spots) { return std::nullopt; if (resolutions.size() < 20) return resolutions[2]; - return resolutions[static_cast(spots.size() * 0.05)]; + return resolutions[static_cast(resolutions.size() * 0.05)]; } -void GenerateSpotPlot(DataMessage &msg, float d_min_A) { +void GenerateSpotPlot(DataMessage &msg, const std::vector &spots, float d_min_A) { const int nshells = 20; ResolutionShells shells(d_min_A, 50.0, nshells); std::vector intensity(nshells); std::vector count(nshells); - for (const auto &s: msg.spots) { + for (const auto &s: spots) { if (s.ice_ring) continue; @@ -140,7 +140,6 @@ void SpotAnalyze(const DiffractionExperiment &experiment, spots_out.push_back(s.value()); } - if (spot_finding_settings.high_res_gap_Q_recipA.has_value()) FilterSpuriousHighResolutionSpots(spots_out, spot_finding_settings.high_res_gap_Q_recipA.value()); @@ -149,7 +148,7 @@ void SpotAnalyze(const DiffractionExperiment &experiment, CountSpots(output, spots_out, spot_finding_settings.cutoff_spot_count_low_res); - GenerateSpotPlot(output, spot_finding_settings.high_resolution_limit); + GenerateSpotPlot(output, spots_out, spot_finding_settings.high_resolution_limit); output.resolution_estimate = GetResolution(spots_out); diff --git a/image_analysis/spot_finding/SpotUtils.h b/image_analysis/spot_finding/SpotUtils.h index 05d183c8..9ba458be 100644 --- a/image_analysis/spot_finding/SpotUtils.h +++ b/image_analysis/spot_finding/SpotUtils.h @@ -5,7 +5,7 @@ #include "../../common/DiffractionSpot.h" -void GenerateSpotPlot(DataMessage &msg, float d_min_A); +void GenerateSpotPlot(DataMessage &msg, const std::vector &spots, float d_min_A); void CountSpots(DataMessage &msg, const DiffractionExperiment& experiment, diff --git a/reader/HDF5MetadataSource.cpp b/reader/HDF5MetadataSource.cpp index 40469725..4a0cb2f3 100644 --- a/reader/HDF5MetadataSource.cpp +++ b/reader/HDF5MetadataSource.cpp @@ -889,7 +889,7 @@ static void ReadSpotsFromFiles(HDF5Object &master_file, message.spot_count_indexed = ReadElementMasterFirst( master_file, source_file, "/entry/MX/peakCountIndexed", master_image, source_image); - GenerateSpotPlot(message, 1.5); + GenerateSpotPlot(message, message.spots, 1.5); } void HDF5MetadataSource::FillPerImage(DataMessage &message, int64_t requested_image, -- 2.54.0 From c52886c8ff14944dfaae748a76b2ae0da01b6248 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 21 Jul 2026 23:02:40 +0200 Subject: [PATCH 002/295] Guard degenerate asymptotic-ISa fit on low-multiplicity data On very-low-multiplicity data (e.g. EP_cs_01-24, mult ~1.4) the merge has too few symmetry equivalents to measure the asymptotic I/sigma: both the (a, b) error-model fit and the per-group strong-reflection scatter collapse toward zero, so 1/error_model_b_asymptotic either explodes to an impossibly high ISa (tiny positive b) or is left as 0. Real macromolecular data does not exceed ISa ~50, so clamp the reported asymptote at a generous cap (ISa 100) and treat anything past it as unmeasured (result.isa undetermined) rather than emitting a spurious extreme. No-op for all well-measured data (b_asy well above the cap). Co-Authored-By: Claude Opus 4.8 (1M context) --- image_analysis/scale_merge/RotationScaleMerge.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index 27012c7a..324a6c40 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -1520,6 +1520,12 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool if (b_asy <= 0.0) b_asy = asymptote_above(10.0, 50); // relaxed for weak / damaged data if (b_asy > 0.0) error_model_b_asymptotic = b_asy; } + // Guard a degenerate low-multiplicity fit: with too few symmetry equivalents both the (a, b) fit and + // the per-group scatter collapse toward zero, and 1/b then reports an impossibly high asymptotic + // I/sigma. Real macromolecular data does not exceed ISa ~50; past a generous cap treat the asymptote + // as unmeasured (result.isa left undetermined) rather than emit a spurious extreme. + constexpr double MIN_ASYMPTOTIC_B = 0.01; // ISa cap 100 + if (error_model_b_asymptotic < MIN_ASYMPTOTIC_B) error_model_b_asymptotic = 0.0; if (error_model_active) logger.Info("Error model: a={:.3f} b={:.3f} ISa={:.1f} chi2={:.2f}", error_model_a, error_model_b, error_model_b_asymptotic > 0 ? 1.0 / error_model_b_asymptotic : 0.0, error_model_chi2); -- 2.54.0 From cbc6a851577a63bc80b2ee96a86de6a18f59cf71 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 22 Jul 2026 22:50:25 +0200 Subject: [PATCH 003/295] Debias stills merge with expected-variance weighting The serial-stills merge (MergeOnTheFly::CorrectedSigma) weighted each observation by 1/sigma^2 using the observation's OWN sigma. Below ~1 photon the Poisson signal part of that sigma correlates with the observation's up/down fluctuation, so the inverse-variance mean is biased low: an up-fluctuated observation acquires a larger sigma and is over-downweighted. The rotation combine (RotationScaleMerge:: process_rawrun) already avoids this by rebuilding the signal variance at the pooled estimate; the stills path did not. Decompose each observation's variance into a background/read part (kept per-observation) and a Poisson signal part, and rebuild the signal part at the reflection's expected . Bit-identical when an observation sits at its reflection mean; only weak-shell weights move. Now default on, so the stills path matches the rotation path; --no-expected-variance-merge restores the old observed-sigma weighting. Validated by paired refinement (phenix, 5 free-set seeds, byte-identical free flags across arms): R-free-neutral on strong lysozyme and lower R-free on weak serial-stills data checked against an independent deposited model (6/6 seeds). The CC1/2 dip on strong data reflects precision, not accuracy. Applies to both offline rugnux and the online broker stills merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- common/ScalingSettings.cpp | 9 +++++++++ common/ScalingSettings.h | 11 +++++++++++ image_analysis/scale_merge/Merge.cpp | 23 ++++++++++++++++++++--- image_analysis/scale_merge/Merge.h | 3 ++- rugnux/RugnuxCommandLine.cpp | 2 ++ rugnux/rugnux_cli.cpp | 9 +++++++++ 6 files changed, 53 insertions(+), 4 deletions(-) diff --git a/common/ScalingSettings.cpp b/common/ScalingSettings.cpp index 01477cb9..288a447e 100644 --- a/common/ScalingSettings.cpp +++ b/common/ScalingSettings.cpp @@ -160,6 +160,15 @@ bool ScalingSettings::GetStillsModulation() const { return stills_modulation; } +ScalingSettings &ScalingSettings::ExpectedVarianceMerge(bool input) { + expected_variance_merge = input; + return *this; +} + +bool ScalingSettings::GetExpectedVarianceMerge() const { + return expected_variance_merge; +} + ScalingSettings &ScalingSettings::SmoothGDegrees(double input) { if (input < 0) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Smooth-G range must be non-negative"); diff --git a/common/ScalingSettings.h b/common/ScalingSettings.h index 74105de7..c541cc61 100644 --- a/common/ScalingSettings.h +++ b/common/ScalingSettings.h @@ -51,6 +51,15 @@ class ScalingSettings { // rotation path fits its own modulation via RotationScaleMerge). Enabled by rugnux --stills-modulation. bool stills_modulation = false; + // Expected-variance merge weighting for the STILLS merge (MergeOnTheFly). When combining a reflection's + // redundant observations by inverse variance, rebuild the Poisson signal part of each observation's + // variance at the reflection's EXPECTED instead of the observation's own intensity. Weighting by an + // observation's own sigma^2 biases the inverse-variance mean low at <1 photon (an up-fluctuated + // observation gets a larger sigma and is over-downweighted). Default on - it mirrors the rotation combine + // (RotationScaleMerge::process_rawrun), which already does this, and is R-free-neutral on strong data and + // better on weak. --no-expected-variance-merge restores the old observed-sigma weighting. + bool expected_variance_merge = true; + // Smooth the per-frame scale G across frames (centered moving average of log G) before the rot3d // combine, so a rocking event's partials share a consistent scale. Given as a ROTATION RANGE in // degrees (like XDS DELPHI), converted to an odd frame window from the oscillation step; this keeps @@ -92,6 +101,7 @@ public: ScalingSettings& AbsorptionIter(int input); ScalingSettings& CorrectionSurfaces(bool input); ScalingSettings& StillsModulation(bool input); + ScalingSettings& ExpectedVarianceMerge(bool input); ScalingSettings& SmoothGDegrees(double input); ScalingSettings& RelativeBDegrees(double input); @@ -131,6 +141,7 @@ public: [[nodiscard]] int GetAbsorptionIter() const; [[nodiscard]] bool GetCorrectionSurfaces() const; [[nodiscard]] bool GetStillsModulation() const; + [[nodiscard]] bool GetExpectedVarianceMerge() const; [[nodiscard]] double GetSmoothGDegrees() const; [[nodiscard]] double GetRelativeBDegrees() const; diff --git a/image_analysis/scale_merge/Merge.cpp b/image_analysis/scale_merge/Merge.cpp index b67484ef..0b7ec2cb 100644 --- a/image_analysis/scale_merge/Merge.cpp +++ b/image_analysis/scale_merge/Merge.cpp @@ -93,7 +93,7 @@ void MergeOnTheFly::AddImage(const IntegrationOutcome &outcome, int64_t image_id continue; auto hkl = generator(r); auto hkl_key = hkl.pack(); - sigma_corr = CorrectedSigma(I_corr, sigma_corr, hkl_key, r.partiality); + sigma_corr = CorrectedSigma(I_corr, sigma_corr, r.image_scale_corr, hkl_key, r.partiality); // Robust outlier rejection: drop this observation if it sits more than // reject_nsigma error-model sigmas from the reflection's median. Needs the active @@ -262,7 +262,8 @@ double MergeOnTheFly::RefineModulation(std::vector &outcomes return gain / std::max(base, 1e-30); // held-out gain fraction (caller logs) } -float MergeOnTheFly::CorrectedSigma(float I_corr, float sigma_corr, uint64_t hkl_key, float partiality) const { +float MergeOnTheFly::CorrectedSigma(float I_corr, float sigma_corr, float image_scale_corr, + uint64_t hkl_key, float partiality) const { if (!error_model_active) return sigma_corr; @@ -271,7 +272,23 @@ float MergeOnTheFly::CorrectedSigma(float I_corr, float sigma_corr, uint64_t hkl const auto it = error_model_mean_I.find(hkl_key); const double I_for_b = (it != error_model_mean_I.end()) ? it->second : I_corr; - double v = error_model_a * static_cast(sigma_corr) * sigma_corr + // Base variance for the a*sigma^2 term. A weak observation's sigma^2 = a background/read part plus a + // Poisson signal part proportional to its OWN intensity; weighting the merge by 1/sigma^2 with that + // per-observation sigma biases the inverse-variance mean low at <1 photon (an up-fluctuated observation + // gets a larger sigma and is over-downweighted, so the weighted mean drifts below ). Rebuild the + // signal part at the reflection's EXPECTED intensity instead - decompose out the background part + // and re-add corr* - so the weight no longer correlates with the observation's own fluctuation. This + // mirrors the rotation combine in RotationScaleMerge::process_rawrun and is bit-identical when the + // observation sits at its reflection mean. + double a_var = static_cast(sigma_corr) * sigma_corr; + if (scaling_settings.GetExpectedVarianceMerge()) { + const double bkg_var = std::max(0.0, a_var - static_cast(image_scale_corr) * I_corr); + const double base = bkg_var + static_cast(image_scale_corr) * std::max(0.0, I_for_b); + if (base > 0.0) + a_var = base; + } + + double v = error_model_a * a_var + (error_model_b * I_for_b) * (error_model_b * I_for_b); // Partiality-model uncertainty: a reflection recorded at fraction p carries a systematic intensity diff --git a/image_analysis/scale_merge/Merge.h b/image_analysis/scale_merge/Merge.h index 441252c9..fdcd42c4 100644 --- a/image_analysis/scale_merge/Merge.h +++ b/image_analysis/scale_merge/Merge.h @@ -129,7 +129,8 @@ class MergeOnTheFly { // observations), so it inflates sigma without biasing the inverse-variance weights - // using the per-observation I_i instead would over-weight down-fluctuated points. std::unordered_map error_model_mean_I; - [[nodiscard]] float CorrectedSigma(float I_corr, float sigma_corr, uint64_t hkl_key, float partiality) const; + [[nodiscard]] float CorrectedSigma(float I_corr, float sigma_corr, float image_scale_corr, + uint64_t hkl_key, float partiality) const; // Optional per-observation outlier rejection: drop observations whose corrected // intensity lies more than reject_nsigma error-model sigmas from the reflection's diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 5e748279..3a0071a5 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -153,6 +153,8 @@ std::string RugnuxCommandLine(const ProcessConfig &config, add("--partiality-uncertainty", num(sc.GetPartialityUncertaintyCoeff())); if (sc.GetStillsModulation()) args.emplace_back("--stills-modulation"); + if (!sc.GetExpectedVarianceMerge()) + args.emplace_back("--no-expected-variance-merge"); // When merging, the CLI skips the large _process.h5 unless asked; emit the flag when it is // wanted so a copied command matches the GUI's "Save _process.h5" choice. (write_merged has // no CLI equivalent - the CLI always writes the .mtz/.cif when merging.) diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 42711113..e9fcfbc4 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -94,6 +94,7 @@ void print_usage() { std::cout << " --relative-b[=deg] rot3d: fit a per-batch relative-B (beyond the single decay slope) over deg-degree batches; cross-validated (default: 10 deg when bare; off otherwise)" << std::endl; std::cout << " --no-scaling-corrections rot3d: disable the (default-on) decay + absorption correction surfaces fitted on the fulls after scale-fulls" << std::endl; std::cout << " --stills-modulation stills: fit a detector-plane modulation (flat-field) surface over the merged reflections (cross-validated; experimental, default off)" << std::endl; + std::cout << " --no-expected-variance-merge stills: disable the default expected-variance merge weighting (which rebuilds each weak observation's signal variance at the reflection mean to de-bias the inverse-variance merge); restores observed-sigma weighting" << std::endl; std::cout << " -A, --anomalous Anomalous mode (don't merge Friedel pairs)" << std::endl; std::cout << " -B, --refine-bfactor Refine per image B-factor (stills only)" << std::endl; std::cout << " --scaling-high-resolution High resolution limit for scaling/merging (manual override; default: no limit)" << std::endl; @@ -176,6 +177,7 @@ enum { OPT_RELATIVE_B, OPT_NO_SCALING_CORRECTIONS, OPT_STILLS_MODULATION, + OPT_NO_EXPECTED_VARIANCE_MERGE, OPT_DETECT_ICE_RINGS, OPT_NO_SCALE_FULLS, OPT_WRITE_PROCESS_H5, @@ -223,6 +225,7 @@ static option long_options[] = { {"relative-b", optional_argument, nullptr, OPT_RELATIVE_B}, {"no-scaling-corrections", no_argument, nullptr, OPT_NO_SCALING_CORRECTIONS}, {"stills-modulation", no_argument, nullptr, OPT_STILLS_MODULATION}, + {"no-expected-variance-merge", no_argument, nullptr, OPT_NO_EXPECTED_VARIANCE_MERGE}, {"refine", required_argument, nullptr, 'r'}, {"two-pass-rotation", optional_argument, nullptr, 'R'}, @@ -506,6 +509,7 @@ int main(int argc, char **argv) { std::optional relative_b_deg_arg; // --relative-b[=deg]; per-batch relative-B width, 0 (off) unless given bool no_scaling_corrections = false; // --no-scaling-corrections: disable rot3d decay+absorption surfaces bool stills_modulation_flag = false; // --stills-modulation: detector-plane flat-field surface for stills + bool no_expected_variance_merge = false; // --no-expected-variance-merge: restore observed-sigma stills merge weighting bool anomalous_mode = false; std::optional space_group_number; std::optional fixed_reference_unit_cell; @@ -792,6 +796,9 @@ int main(int argc, char **argv) { case OPT_STILLS_MODULATION: stills_modulation_flag = true; break; + case OPT_NO_EXPECTED_VARIANCE_MERGE: + no_expected_variance_merge = true; + break; case OPT_NO_SCALING_CORRECTIONS: no_scaling_corrections = true; break; @@ -1074,6 +1081,7 @@ int main(int argc, char **argv) { (experiment.GetGoniometer().has_value() && !force_still) ? 0.7 : 0.0)); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is percent; the setting is a fraction scaling_settings.StillsModulation(stills_modulation_flag); + scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or( (experiment.GetGoniometer().has_value() && !force_still) ? REJECT_OUTLIERS_DEFAULT_NSIGMA : 0.0)); @@ -1386,6 +1394,7 @@ int main(int argc, char **argv) { if (no_scaling_corrections) scaling_settings.CorrectionSurfaces(false); scaling_settings.StillsModulation(stills_modulation_flag); + scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); if (d_min_scale_merge) scaling_settings.HighResolutionLimit_A(d_min_scale_merge.value()); if (resolution_cutoff_method) scaling_settings.ResolutionCutoff(*resolution_cutoff_method); -- 2.54.0 From 1a2b0181a5877a3d3ccc803a9bcd0a45cdcf11e7 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 23 Jul 2026 14:45:43 +0200 Subject: [PATCH 004/295] Add physical partiality post-refinement for stills (default on) Replace the frozen scalar-sigma stills partiality with a physical, refined model. Per crystal, refine an orientation tilt (dpsi_x, dpsi_y) against the running merge and recompute each reflection's partiality analytically from the refined geometry (angular Ewald-proximity model, sigma(d*) = gamma_e*d*), with the per-crystal scale G profiled out by the existing robust IRLS - no re-integration. A soft Gaussian prior on dpsi tames weak-data overfit while staying inert on strong data. The merge <-> refine loop iterates a few times. This is now the stills default via ScalingSettings::stills_partiality_refine (on). A single opt-out flag `--simple-stills` reverts to treating every reflection as a full (p=1, single pass). Retires the experimental `--still-partiality` flag. The viewer gains a "Partiality post-refinement (stills)" checkbox in Scaling settings. Validated (integrate-once / --scale): CC1/2 and R_meas both improve on three monochromatic serial-stills datasets (+2.8 / -10, +5.6 / -3.4, +2.1 / -4); neutral on a pink-beam DMM set (already-full reflections); R-free/R-work down vs a fixed model; competitive with CrystFEL partialator on matched frames. Co-Authored-By: Claude Opus 4.8 (1M context) --- common/ScalingSettings.cpp | 9 + common/ScalingSettings.h | 9 + image_analysis/scale_merge/CMakeLists.txt | 2 + .../scale_merge/StillsPartialityRefine.cpp | 329 ++++++++++++++++++ .../scale_merge/StillsPartialityRefine.h | 59 ++++ rugnux/Rugnux.cpp | 10 + rugnux/RugnuxCommandLine.cpp | 4 +- rugnux/rugnux_cli.cpp | 32 +- viewer/widgets/JFJochViewerSettingsDock.cpp | 26 +- 9 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 image_analysis/scale_merge/StillsPartialityRefine.cpp create mode 100644 image_analysis/scale_merge/StillsPartialityRefine.h diff --git a/common/ScalingSettings.cpp b/common/ScalingSettings.cpp index 288a447e..08ff80c6 100644 --- a/common/ScalingSettings.cpp +++ b/common/ScalingSettings.cpp @@ -160,6 +160,15 @@ bool ScalingSettings::GetStillsModulation() const { return stills_modulation; } +ScalingSettings &ScalingSettings::StillsPartialityRefine(bool input) { + stills_partiality_refine = input; + return *this; +} + +bool ScalingSettings::GetStillsPartialityRefine() const { + return stills_partiality_refine; +} + ScalingSettings &ScalingSettings::ExpectedVarianceMerge(bool input) { expected_variance_merge = input; return *this; diff --git a/common/ScalingSettings.h b/common/ScalingSettings.h index c541cc61..f215709f 100644 --- a/common/ScalingSettings.h +++ b/common/ScalingSettings.h @@ -51,6 +51,13 @@ class ScalingSettings { // rotation path fits its own modulation via RotationScaleMerge). Enabled by rugnux --stills-modulation. bool stills_modulation = false; + // Physical partiality post-refinement for the STILLS merge (StillsPartialityRefine): refine a per-crystal + // orientation tilt against the running merge, recompute each reflection's partiality from the refined + // geometry (angular Ewald-proximity model), and re-scale/merge - the "full model" for stills. ON by + // default (helps mono stills, neutral on pink beam, tames weak data via a soft prior). rugnux + // --simple-stills turns it OFF, reverting to treating every reflection as a full (p = 1, single pass). + bool stills_partiality_refine = true; + // Expected-variance merge weighting for the STILLS merge (MergeOnTheFly). When combining a reflection's // redundant observations by inverse variance, rebuild the Poisson signal part of each observation's // variance at the reflection's EXPECTED instead of the observation's own intensity. Weighting by an @@ -101,6 +108,7 @@ public: ScalingSettings& AbsorptionIter(int input); ScalingSettings& CorrectionSurfaces(bool input); ScalingSettings& StillsModulation(bool input); + ScalingSettings& StillsPartialityRefine(bool input); ScalingSettings& ExpectedVarianceMerge(bool input); ScalingSettings& SmoothGDegrees(double input); ScalingSettings& RelativeBDegrees(double input); @@ -141,6 +149,7 @@ public: [[nodiscard]] int GetAbsorptionIter() const; [[nodiscard]] bool GetCorrectionSurfaces() const; [[nodiscard]] bool GetStillsModulation() const; + [[nodiscard]] bool GetStillsPartialityRefine() const; [[nodiscard]] bool GetExpectedVarianceMerge() const; [[nodiscard]] double GetSmoothGDegrees() const; [[nodiscard]] double GetRelativeBDegrees() const; diff --git a/image_analysis/scale_merge/CMakeLists.txt b/image_analysis/scale_merge/CMakeLists.txt index 1c172a2f..338b1290 100644 --- a/image_analysis/scale_merge/CMakeLists.txt +++ b/image_analysis/scale_merge/CMakeLists.txt @@ -7,6 +7,8 @@ ADD_LIBRARY(JFJochScaleMerge Merge.h ScaleOnTheFly.cpp ScaleOnTheFly.h + StillsPartialityRefine.cpp + StillsPartialityRefine.h RotationScaleMerge.cpp RotationScaleMerge.h ResolutionCutoff.cpp diff --git a/image_analysis/scale_merge/StillsPartialityRefine.cpp b/image_analysis/scale_merge/StillsPartialityRefine.cpp new file mode 100644 index 00000000..03f60189 --- /dev/null +++ b/image_analysis/scale_merge/StillsPartialityRefine.cpp @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "StillsPartialityRefine.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "Merge.h" + +namespace { + constexpr size_t MIN_FIT_REFLECTIONS = 20; + constexpr double kRadToDeg = 180.0 / 3.14159265358979323846; + + double SafeInv(double x, double fallback) { + if (!std::isfinite(x) || x == 0.0) + return fallback; + return 1.0 / x; + } + + // One accepted reflection reduced to the physical partiality fit: the base reciprocal vector q (crystal + // frame at the stored per-image orientation), the reference full intensity, the measured intensity, the + // Lorentz factor and the weight. dist_ewald / partiality are recomputed from q under the refined tilt. + struct FitObs { + double qx, qy, qz; + double Iref; + double Iobs; + double lp; // 1 / rlp + double weight; // 1 / sigma + }; + + double VecLen(double x, double y, double z) { return std::sqrt(x * x + y * y + z * z); } + + // Analytic partiality for a reflection whose base reciprocal vector is q, tilted by (psi_x, psi_y). + // Mirrors BraggPrediction: dist_ewald = |S| - 1/lambda with S = q_rot + S0, and + // p = exp(-dist_ewald^2 / 2 sigma^2), sigma^2 = (gamma0 + gamma_e*d*)^2 + (bw*|q_z|)^2. + double ComputeP(double qx, double qy, double qz, + double psi_x, double psi_y, + double s0x, double s0y, double s0z, double inv_lambda, + double gamma0, double gamma_e, double bw) { + double aa[3] = {psi_x, psi_y, 0.0}; + double q[3] = {qx, qy, qz}; + double qr[3]; + ceres::AngleAxisRotatePoint(aa, q, qr); + const double Sx = qr[0] + s0x, Sy = qr[1] + s0y, Sz = qr[2] + s0z; + const double de = std::sqrt(Sx * Sx + Sy * Sy + Sz * Sz) - inv_lambda; + const double dstar = std::sqrt(qr[0] * qr[0] + qr[1] * qr[1] + qr[2] * qr[2]); + const double sig = gamma0 + gamma_e * dstar; + const double sbw = bw * std::fabs(qr[2]); + const double sig2 = sig * sig + sbw * sbw; + if (!(sig2 > 0.0)) + return 1.0; + return std::exp(-0.5 * de * de / sig2); + } + + // Robust per-crystal scale G (linear in G given the model coefficients), identical objective to + // ScaleOnTheFly::SolveScaleIRLS: minimise sum Cauchy_k( w (G*coeff - Iobs) ) over G >= 0. + double SolveScaleIRLS(const std::vector &coeff, const std::vector &Iobs, + const std::vector &weight, double robust_k) { + auto weighted_scale = [&](auto robust_weight) { + double num = 0.0, den = 0.0; + for (size_t i = 0; i < coeff.size(); ++i) { + const double rw = robust_weight(i); + const double w2 = weight[i] * weight[i]; + num += rw * w2 * coeff[i] * Iobs[i]; + den += rw * w2 * coeff[i] * coeff[i]; + } + return den > 0.0 ? num / den : NAN; + }; + + double G = weighted_scale([](size_t) { return 1.0; }); + if (!std::isfinite(G)) + return 1.0; + G = std::max(0.0, G); + + const double k2 = robust_k * robust_k; + for (int iter = 0; iter < 30; ++iter) { + const double G_prev = G; + const double G_next = weighted_scale([&](size_t i) { + const double res = weight[i] * (G * coeff[i] - Iobs[i]); + return 1.0 / (1.0 + res * res / k2); + }); + if (!std::isfinite(G_next)) + break; + G = std::max(0.0, G_next); + if (std::abs(G - G_prev) <= 1e-7 * std::max(G, 1.0)) + break; + } + return G; + } + + // Ceres residual: refine the orientation tilt (psi_x, psi_y) holding the scale G fixed. The tilt + // rotates the base reciprocal vector q; partiality follows analytically. Residual is the intensity + // mismatch weighted by 1/sigma, exactly matching ScaleOnTheFly's intensity-space objective. + struct PsiResidual { + double qx, qy, qz; + double s0x, s0y, s0z, inv_lambda; + double gamma0, gamma_e, bw; + double G, lp, Iref, Iobs, weight; + + template + bool operator()(const T *const psi, T *residual) const { + T q[3] = {T(qx), T(qy), T(qz)}; + T aa[3] = {psi[0], psi[1], T(0.0)}; + T qr[3]; + ceres::AngleAxisRotatePoint(aa, q, qr); + const T Sx = qr[0] + T(s0x), Sy = qr[1] + T(s0y), Sz = qr[2] + T(s0z); + const T de = ceres::sqrt(Sx * Sx + Sy * Sy + Sz * Sz) - T(inv_lambda); + const T dstar = ceres::sqrt(qr[0] * qr[0] + qr[1] * qr[1] + qr[2] * qr[2]); + const T sig = T(gamma0) + T(gamma_e) * dstar; + const T sbw = T(bw) * ceres::abs(qr[2]); + const T sig2 = sig * sig + sbw * sbw; + const T p = ceres::exp(T(-0.5) * de * de / sig2); + residual[0] = T(weight) * (T(G) * p * T(lp) * T(Iref) - T(Iobs)); + return true; + } + }; + + // Gaussian prior N(0, sigma_prior^2) on the tilt. The data residuals above are (model-obs)/sigma, a + // proper chi^2, so the MAP prior residual is simply dpsi/sigma_prior - no scale calibration needed. + struct PsiPrior { + double inv_sigma; + template + bool operator()(const T *const psi, T *residual) const { + residual[0] = T(inv_sigma) * psi[0]; + residual[1] = T(inv_sigma) * psi[1]; + return true; + } + }; +} + +StillsPartialityRefine::StillsPartialityRefine(const DiffractionExperiment &x) + : StillsPartialityRefine(x, Settings{}) {} + +StillsPartialityRefine::StillsPartialityRefine(const DiffractionExperiment &x, Settings settings) + : experiment_(x), + settings_(settings), + hkl_key_generator_(x.GetScalingSettings().GetMergeFriedel(), x.GetSpaceGroupNumber().value_or(1)), + d_min_limit_(x.GetScalingSettings().GetHighResolutionLimit_A()), + bandwidth_sigma_(x.GetBandwidthFWHM().value_or(0.0f) / 2.3548f) {} + +double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, + const std::map &reference) const { + if (outcome.reflections.empty()) + return 0.0; + + const Coord Astar = outcome.latt.Astar(); + const Coord Bstar = outcome.latt.Bstar(); + const Coord Cstar = outcome.latt.Cstar(); + const Coord S0 = outcome.geom.GetScatteringVector(); + const double inv_lambda = 1.0 / outcome.geom.GetWavelength_A(); + const double bw = bandwidth_sigma_; + const double gamma0 = 0.0; // width is purely angular: sigma(d*) = gamma_e * d* (set per crystal below) + + auto base_q = [&](const Reflection &r) { + return Astar * static_cast(r.h) + Bstar * static_cast(r.k) + + Cstar * static_cast(r.l); + }; + + // Collect the reflections that constrain the fit (accepted, non-ice, finite, present in the reference). + std::vector obs; + obs.reserve(outcome.reflections.size()); + double sum_ang2 = 0.0; // RMS angular excitation error (dist_ewald / d*) -> per-crystal mosaic width + size_t n_de = 0; + for (const Reflection &r: outcome.reflections) { + if (r.on_ice_ring || !AcceptReflection(r, d_min_limit_)) + continue; + if (!std::isfinite(r.I) || !std::isfinite(r.sigma) || r.sigma <= 0.0f) + continue; + const auto it = reference.find(hkl_key_generator_(r)); + if (it == reference.end() || !std::isfinite(it->second)) + continue; + + const Coord q = base_q(r); + obs.push_back(FitObs{ + .qx = q.x, .qy = q.y, .qz = q.z, + .Iref = it->second, + .Iobs = static_cast(r.I), + .lp = SafeInv(r.rlp, 1.0), + .weight = SafeInv(r.sigma, 1.0), + }); + + // Angular excitation error delta_psi = dist_ewald / d* at the stored orientation (psi = 0). Using + // the ANGULAR distance (not the linear reciprocal-space distance) makes the partiality width + // resolution-clean: a fixed mosaic angle smears high-resolution rlps more in reciprocal space, so a + // constant linear width computes p too small at high resolution and over-divides those shells. + const double dstar = VecLen(q.x, q.y, q.z); + const double de0 = VecLen(q.x + S0.x, q.y + S0.y, q.z + S0.z) - inv_lambda; + if (dstar > 1e-9) { + const double dpsi = de0 / dstar; + sum_ang2 += dpsi * dpsi; + ++n_de; + } + } + + if (obs.size() < MIN_FIT_REFLECTIONS || n_de == 0) + return 0.0; + + // Per-crystal angular mosaic width from the RMS angular excitation error. sigma(d*) = gamma_e * d* + // (gamma0 = 0), i.e. p = exp(-0.5 (delta_psi / gamma_e)^2) is a Gaussian in the angular distance from + // the Ewald sphere - the physical mosaic/divergence model, independent of resolution. A positive + // settings_.gamma_e overrides the per-crystal estimate with a shared (pooled) width. + const double gamma_e_ang = std::max(std::sqrt(sum_ang2 / static_cast(n_de)), 1e-9); + const double gamma_e = settings_.gamma_e > 0.0 ? settings_.gamma_e : gamma_e_ang; + + double psi[2] = {0.0, 0.0}; + double G = 1.0; + + const bool refine_tilt = obs.size() >= settings_.min_reflections; + const int inner = refine_tilt ? settings_.inner_iterations : 1; + + for (int it = 0; it < inner; ++it) { + // (1) Solve G given the current partialities. + std::vector coeff(obs.size()), Iobs(obs.size()), weight(obs.size()); + for (size_t j = 0; j < obs.size(); ++j) { + const double p = ComputeP(obs[j].qx, obs[j].qy, obs[j].qz, psi[0], psi[1], + S0.x, S0.y, S0.z, inv_lambda, gamma0, gamma_e, bw); + coeff[j] = p * obs[j].lp * obs[j].Iref; + Iobs[j] = obs[j].Iobs; + weight[j] = obs[j].weight; + } + G = SolveScaleIRLS(coeff, Iobs, weight, settings_.robust_k); + if (!(G > 0.0) || !std::isfinite(G)) + return 0.0; + + if (!refine_tilt) + break; + + // (2) Refine the tilt holding G fixed. + ceres::Problem problem; + for (const auto &o: obs) { + auto *cost = new ceres::AutoDiffCostFunction(new PsiResidual{ + .qx = o.qx, .qy = o.qy, .qz = o.qz, + .s0x = S0.x, .s0y = S0.y, .s0z = S0.z, .inv_lambda = inv_lambda, + .gamma0 = gamma0, .gamma_e = gamma_e, .bw = bw, + .G = G, .lp = o.lp, .Iref = o.Iref, .Iobs = o.Iobs, .weight = o.weight}); + problem.AddResidualBlock(cost, new ceres::CauchyLoss(settings_.robust_k), psi); + } + if (settings_.prior_sigma_deg > 0.0) { + const double inv_sigma = kRadToDeg / settings_.prior_sigma_deg; // 1 / sigma_prior (rad) + problem.AddResidualBlock(new ceres::AutoDiffCostFunction( + new PsiPrior{inv_sigma}), nullptr, psi); + } + problem.SetParameterLowerBound(psi, 0, -settings_.max_tilt_rad); + problem.SetParameterUpperBound(psi, 0, settings_.max_tilt_rad); + problem.SetParameterLowerBound(psi, 1, -settings_.max_tilt_rad); + problem.SetParameterUpperBound(psi, 1, settings_.max_tilt_rad); + + ceres::Solver::Options options; + options.linear_solver_type = ceres::DENSE_QR; + options.minimizer_progress_to_stdout = false; + options.num_threads = 1; + options.max_num_iterations = 25; + ceres::Solver::Summary summary; + ceres::Solve(options, &problem, &summary); + } + + // Write the refined partiality + scale correction onto every reflection of the crystal (not only the + // fit subset), so the merge sees a consistent model. image_scale_corr = rlp / (partiality * G). + for (auto &r: outcome.reflections) { + const Coord q = base_q(r); + const double p = ComputeP(q.x, q.y, q.z, psi[0], psi[1], S0.x, S0.y, S0.z, inv_lambda, + gamma0, gamma_e, bw); + r.partiality = static_cast(p); + const double denom = p * G; + r.image_scale_corr = (std::isfinite(r.rlp) && denom > 0.0) + ? static_cast(r.rlp / denom) + : NAN; + } + outcome.image_scale_g = static_cast(G); + + const double tilt_deg = std::sqrt(psi[0] * psi[0] + psi[1] * psi[1]) * kRadToDeg; + return tilt_deg; +} + +double StillsPartialityRefine::Run(std::vector &outcomes, size_t nthreads) const { + if (nthreads == 0) + nthreads = std::thread::hardware_concurrency(); + nthreads = std::max(1, nthreads); + + double last_mean_tilt = 0.0; + + for (int outer = 0; outer < settings_.outer_iterations; ++outer) { + // Reference full intensities from the current corrections. + const std::vector merged = MergeAll(experiment_, outcomes, false); + std::map reference; + for (const auto &m: merged) + reference[hkl_key_generator_(m)] = m.I; + + std::atomic tilt_sum{0.0}; + std::atomic tilt_n{0}; + std::atomic next{0}; + + auto worker = [&]() { + size_t i = next.fetch_add(1); + while (i < outcomes.size()) { + const double t = RefineOne(outcomes[i], reference); + if (t > 0.0) { + double prev = tilt_sum.load(); + while (!tilt_sum.compare_exchange_weak(prev, prev + t)) {} + tilt_n.fetch_add(1); + } + i = next.fetch_add(1); + } + }; + + const size_t nt = std::min(nthreads, std::max(1, outcomes.size())); + if (nt <= 1) { + worker(); + } else { + std::vector> futures; + futures.reserve(nt); + for (size_t t = 0; t < nt; ++t) + futures.emplace_back(std::async(std::launch::async, worker)); + for (auto &f: futures) + f.get(); + } + + last_mean_tilt = tilt_n > 0 ? tilt_sum.load() / static_cast(tilt_n.load()) : 0.0; + } + + return last_mean_tilt; +} diff --git a/image_analysis/scale_merge/StillsPartialityRefine.h b/image_analysis/scale_merge/StillsPartialityRefine.h new file mode 100644 index 00000000..108f7cac --- /dev/null +++ b/image_analysis/scale_merge/StillsPartialityRefine.h @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "../../common/DiffractionExperiment.h" +#include "../IntegrationOutcome.h" +#include "HKLKey.h" + +// Experimental physical partiality post-refinement for STILLS (env JFJOCH_STILL_POSTREFINE). +// +// The default stills partiality is a frozen scalar-sigma Gaussian (or p == 1): p is set once at +// prediction and never optimised, and any attempt to free a per-image sigma jointly with the per-image +// scale G collapses (within one still the excitation-error spread is narrow, so a scalar sigma is +// degenerate with G). This class instead parametrises partiality by the crystal ORIENTATION - a small +// tilt (dpsi_x, dpsi_y) about the two axes perpendicular to the beam - shared by all of a crystal's +// reflections. A tilt moves each reflection's excitation error by an amount that depends on where the +// reflection sits on the pattern (one side of the Ewald sphere approaches, the opposite recedes), so it +// reshapes the SPATIAL pattern of partialities in a way a single G cannot mimic. That breaks the +// degeneracy that killed the scalar-sigma fit. +// +// Because the integrated intensity I_obs is fixed, refining the tilt only recomputes the partiality +// analytically from the stored per-image lattice/geometry (q = A*.h + B*.k + C*.l, then +// dist_ewald = | |q + S0| - 1/lambda |) - NO pixel re-integration. The loop is: merge -> per-crystal +// refine tilt (G profiled out by the same robust IRLS ScaleOnTheFly uses) -> recompute p and +// image_scale_corr -> re-merge, iterated a few times. Mutates each reflection's `partiality` and +// `image_scale_corr` in place; the existing MergeOnTheFly then consumes the improved corrections. +class StillsPartialityRefine { +public: + struct Settings { + int outer_iterations = 2; // merge <-> refine cycles + int inner_iterations = 3; // (solve G) <-> (refine tilt) alternations per crystal + size_t min_reflections = 40; // skip tilt refinement below this (anti-overfit on sparse crystals) + double max_tilt_rad = 0.0175; // hard bound on |dpsi| (~1 deg); indexing already refined orientation + double prior_sigma_deg = 0.02; // soft prior pulling dpsi toward 0 (0 = off); tames weak-data overfit, + // inert on strong data (well-supported tilts overcome it) + double robust_k = 3.0; // Cauchy loss scale (sigma units) + double gamma_e = 0.0; // angular width sigma(d*) = gamma_e*d* (0 = estimate per crystal from data) + }; + + explicit StillsPartialityRefine(const DiffractionExperiment &x); + StillsPartialityRefine(const DiffractionExperiment &x, Settings settings); + + // Refine all crystals in place. Returns the mean |dpsi| applied (degrees), for diagnostics. + double Run(std::vector &outcomes, size_t nthreads = 0) const; + +private: + const DiffractionExperiment experiment_; + const Settings settings_; + const HKLKeyGenerator hkl_key_generator_; + const std::optional d_min_limit_; + const float bandwidth_sigma_; + + // Refine one crystal against the reference map; returns |dpsi| in degrees (0 if skipped). + double RefineOne(IntegrationOutcome &outcome, + const std::map &reference) const; +}; diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 8502d569..172a6d76 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -40,6 +40,7 @@ #include "../image_analysis/lattice_search/LatticeSearch.h" #include "../image_analysis/scale_merge/TwinningAnalysis.h" #include "../image_analysis/scale_merge/HKLKey.h" +#include "../image_analysis/scale_merge/StillsPartialityRefine.h" #include "../image_analysis/WriteReflections.h" #include "../image_analysis/bragg_integration/CalcISigma.h" #include "../common/Definitions.h" @@ -970,6 +971,15 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b auto merge_result = MergeAll(experiment_, indexer->GetIntegrationOutcome(), false); indexer->ScaleAllImages(merge_result); } + // Physical partiality post-refinement (default on; --simple-stills disables): refine a per-crystal + // orientation tilt against the running merge and recompute each reflection's partiality + scale + // correction (no re-integration). Never for the P1 search pass. + if (!for_search && experiment_.GetScalingSettings().GetStillsPartialityRefine()) { + phase("Partiality post-refine (" + label + ")"); + StillsPartialityRefine refiner(experiment_); + const double mean_tilt = refiner.Run(indexer->GetIntegrationOutcome(), config_.nthreads); + logger.Info("Stills partiality post-refine: mean |dpsi| = {:.3f} deg", mean_tilt); + } const std::vector &merge_input = indexer->GetIntegrationOutcome(); phase("Merging"); diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 3a0071a5..4d8450a6 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -106,8 +106,6 @@ std::string RugnuxCommandLine(const ProcessConfig &config, std::ostringstream radii; radii << bragg.GetR1() << "," << bragg.GetR2() << "," << bragg.GetR3(); add("--integration-radius", radii.str()); - if (bragg.GetStillPartiality()) - args.emplace_back("--still-partiality"); // Background trim defaults to 0.10 in the CLI, so emit it whenever the GUI value differs (a // custom fraction, or 0 when the box is unchecked) to reproduce the GUI's choice faithfully. if (bragg.GetBackgroundTrimFraction() != 0.10f) @@ -153,6 +151,8 @@ std::string RugnuxCommandLine(const ProcessConfig &config, add("--partiality-uncertainty", num(sc.GetPartialityUncertaintyCoeff())); if (sc.GetStillsModulation()) args.emplace_back("--stills-modulation"); + if (!sc.GetStillsPartialityRefine()) + args.emplace_back("--simple-stills"); if (!sc.GetExpectedVarianceMerge()) args.emplace_back("--no-expected-variance-merge"); // When merging, the CLI skips the large _process.h5 unless asked; emit the flag when it is diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index e9fcfbc4..eafc7967 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -27,6 +27,7 @@ #include "../image_analysis/scale_merge/Merge.h" #include "../image_analysis/scale_merge/RfreeFlags.h" #include "../image_analysis/scale_merge/ScaleOnTheFly.h" +#include "../image_analysis/scale_merge/StillsPartialityRefine.h" #include "../image_analysis/scale_merge/RotationScaleMerge.h" #include "../image_analysis/scale_merge/ResolutionCutoff.h" #include "../image_analysis/scale_merge/TwinningAnalysis.h" @@ -103,7 +104,7 @@ void print_usage() { std::cout << " --resolution-shells Number of resolution shells in the reported statistics table (default: 10)" << std::endl; std::cout << " --min-partiality Minimum partiality to accept reflection (default: 0.02)" << std::endl; std::cout << " --capture-uncertainty rot3d: systematic sigma ~num*(1-captured_fraction)*I on under-captured fulls (default: 1.0 for rot3d, 0 otherwise)" << std::endl; - std::cout << " --partiality-uncertainty stills: extra merge sigma ~num*(1-partiality)* on partials (use with --still-partiality; auto-gated to error-model b>1 / ISa<1; default 0, ~2.5 recommended)" << std::endl; + std::cout << " --partiality-uncertainty stills: extra merge sigma ~num*(1-partiality)* on partials (auto-gated to error-model b>1 / ISa<1; default 0, ~2.5 recommended)" << std::endl; std::cout << " --min-captured-fraction rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl; std::cout << " --mosaicity Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl; std::cout << " --reject-outliers Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl; @@ -120,7 +121,7 @@ void print_usage() { std::cout << " --integration-radius Signal-box radius r1, or r1,r2,r3 (px). One value => r2=r1+2, r3=r1+4" << std::endl; std::cout << " --background-trim Monochromatic (rotation + still): symmetric trimmed-mean fraction for the background ring (0<=f<0.5, default 0.10; 0 = plain mean). Removes the high-side bias that over-subtracts weak high-angle spots (broadband data keep the sigma-clip instead)" << std::endl; std::cout << " --integrator Spot integrator boxsum|gaussian|empirical (default: gaussian profile-fit; boxsum is the classical fallback)" << std::endl; - std::cout << " --still-partiality Experimental: weight stills reflections by a Gaussian excitation-error partiality exp(-dist_ewald^2/2sigma^2) instead of treating each as a full" << std::endl; + std::cout << " --simple-stills stills: treat every reflection as a full (p=1, single-pass scale/merge); disables the default physical partiality post-refinement" << std::endl; std::cout << " -q, --azim-q-spacing Azimuthal-integration Q bin spacing (1/A) (default: 0.01)" << std::endl; std::cout << " --azim-min-q Azimuthal-integration minimum Q (1/A)" << std::endl; std::cout << " --azim-max-q Azimuthal-integration maximum Q (1/A)" << std::endl; @@ -167,7 +168,7 @@ enum { OPT_MODEL, OPT_DUMP_OBSERVATIONS, OPT_INTEGRATOR, - OPT_STILL_PARTIALITY, + OPT_SIMPLE_STILLS, OPT_SCALE_FULLS, OPT_CAPTURE_UNCERTAINTY, OPT_PARTIALITY_UNCERTAINTY, @@ -271,7 +272,7 @@ static option long_options[] = { {"integration-radius", required_argument, nullptr, OPT_INTEGRATION_RADIUS}, {"background-trim", required_argument, nullptr, OPT_BACKGROUND_TRIM}, {"integrator", required_argument, nullptr, OPT_INTEGRATOR}, - {"still-partiality", no_argument, nullptr, OPT_STILL_PARTIALITY}, + {"simple-stills", no_argument, nullptr, OPT_SIMPLE_STILLS}, {"detect-ice-rings", optional_argument, nullptr, OPT_DETECT_ICE_RINGS}, {"reject-outliers", required_argument, nullptr, OPT_REJECT_OUTLIERS}, {"reject-delta-cchalf", required_argument, nullptr, OPT_REJECT_DELTA_CCHALF}, @@ -547,7 +548,7 @@ int main(int argc, char **argv) { std::optional integration_radius_arg; // "r1" or "r1,r2,r3" std::optional background_trim_arg; // --background-trim: background-ring trimmed-mean fraction std::optional integrator_mode; // --integrator boxsum|gaussian|empirical - bool still_partiality_flag = false; // --still-partiality (experimental stills partiality) + bool simple_stills_flag = false; // --simple-stills: disable the default stills partiality post-refinement std::optional outlier_reject_nsigma; // merge per-observation outlier rejection std::optional delta_cchalf_nsigma; // per-crystal CC1/2-delta rejection @@ -829,8 +830,8 @@ int main(int argc, char **argv) { else if (strcmp(optarg, "empirical") == 0) integrator_mode = IntegratorMode::ProfileEmpirical; else { logger.Error("--integrator expects boxsum|gaussian|empirical"); return 1; } break; - case OPT_STILL_PARTIALITY: - still_partiality_flag = true; + case OPT_SIMPLE_STILLS: + simple_stills_flag = true; break; case OPT_REJECT_OUTLIERS: outlier_reject_nsigma = parse_double_arg(optarg, "--reject-outliers", logger); @@ -1081,6 +1082,7 @@ int main(int argc, char **argv) { (experiment.GetGoniometer().has_value() && !force_still) ? 0.7 : 0.0)); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is percent; the setting is a fraction scaling_settings.StillsModulation(stills_modulation_flag); + scaling_settings.StillsPartialityRefine(!simple_stills_flag); scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or( @@ -1137,6 +1139,14 @@ int main(int argc, char **argv) { // the data's own merge. The per-image scale G is the exact one-pass solution, so one pass // (iterating a self-rebuilt reference only re-fits the freshly-scaled noise on weak stills). ScaleOnTheFly(experiment, MergeAll(experiment, reflections)).Scale(reflections, nthreads); + // Physical partiality post-refinement (default on; --simple-stills disables): refine a per-crystal + // orientation tilt against the merge and recompute each reflection's partiality + scale correction + // (no re-integration), then merge with the improved corrections. + if (experiment.GetScalingSettings().GetStillsPartialityRefine()) { + StillsPartialityRefine refiner(experiment); + const double mean_tilt = refiner.Run(reflections, nthreads); + logger.Info("Stills partiality post-refine: mean |dpsi| = {:.3f} deg", mean_tilt); + } MergeOnTheFly merge_engine(experiment); merge_engine.ReferenceCell(experiment.GetUnitCell()); // Optional detector-plane modulation (flat-field) correction, folded into each reflection's @@ -1394,6 +1404,7 @@ int main(int argc, char **argv) { if (no_scaling_corrections) scaling_settings.CorrectionSurfaces(false); scaling_settings.StillsModulation(stills_modulation_flag); + scaling_settings.StillsPartialityRefine(!simple_stills_flag); scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); if (d_min_scale_merge) scaling_settings.HighResolutionLimit_A(d_min_scale_merge.value()); @@ -1469,13 +1480,6 @@ int main(int argc, char **argv) { *background_trim_arg); } - if (still_partiality_flag) { - BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings(); - bis.StillPartiality(true); - experiment.ImportBraggIntegrationSettings(bis); - logger.Info("Stills partiality enabled (experimental Gaussian excitation-error weighting)"); - } - SpotFindingSettings spot_settings; spot_settings.enable = true; spot_settings.indexing = true; diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index ee2087aa..fea318b9 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -446,11 +446,6 @@ QWidget *JFJochViewerSettingsDock::BuildBraggSection() { auto *r3 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR3(), 1, "px", this); auto *radii = new QHBoxLayout(); radii->addWidget(r1); radii->addWidget(r2); radii->addWidget(r3); - auto *stillPartiality = new QCheckBox("Stills partiality (experimental)", this); - stillPartiality->setChecked(bragg_.GetStillPartiality()); - stillPartiality->setToolTip("Experimental, stills only: weight each reflection by a Gaussian " - "excitation-error partiality exp(-d_ewald²/2σ²) instead of treating it as a " - "full. Pairs with \"Partiality uncertainty\" in Scaling."); // Background trim: replace the r2..r3 ring mean with a symmetric trimmed mean (drop the lowest and // highest fraction of ring pixels), which removes the high-side bias that over-subtracts weak // high-angle reflections. Checkbox + fraction; 0 = plain mean. @@ -468,7 +463,6 @@ QWidget *JFJochViewerSettingsDock::BuildBraggSection() { trimRow->addWidget(bkgTrimFrac, 1); form->addRow("", gaussian); form->addRow("Radii r1/r2/r3", radii); - form->addRow("", stillPartiality); form->addRow("", trimRow); section->setContentLayout(form); section->setExpanded(false); // folded on start (only geometry + unit cell start open) @@ -477,12 +471,10 @@ QWidget *JFJochViewerSettingsDock::BuildBraggSection() { bragg_.Integrator(gaussian->isChecked() ? IntegratorMode::ProfileGaussian : IntegratorMode::BoxSum); bragg_.R1(static_cast(r1->value())).R2(static_cast(r2->value())) .R3(static_cast(r3->value())); - bragg_.StillPartiality(stillPartiality->isChecked()); bragg_.BackgroundTrimFraction(bkgTrim->isChecked() ? static_cast(bkgTrimFrac->value()) : 0.0f); emit braggChanged(bragg_); }; connect(gaussian, &QCheckBox::toggled, this, [emitBragg] { emitBragg(); }); - connect(stillPartiality, &QCheckBox::toggled, this, [emitBragg] { emitBragg(); }); connect(bkgTrim, &QCheckBox::toggled, this, [bkgTrim, bkgTrimFrac, emitBragg] { bkgTrimFrac->setEnabled(bkgTrim->isChecked()); emitBragg(); }); connect(bkgTrimFrac, &NumberLineEdit::newValue, this, [emitBragg] { emitBragg(); }); @@ -512,20 +504,27 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { modulation->setToolTip("Stills: fit a detector-plane modulation (flat-field) surface over where each " "reflection lands, cross-validated so it no-ops when the systematic is absent. " "For rotation, modulation is part of \"Correction surfaces\" above."); + auto *partRefine = new QCheckBox("Partiality post-refinement (stills)", this); + partRefine->setChecked(scaling_.GetStillsPartialityRefine()); + partRefine->setToolTip("Stills: refine a per-crystal orientation tilt against the running merge and " + "recompute each reflection's partiality (physical Ewald-proximity model), then " + "re-scale/merge. On by default; uncheck for the simple model (each reflection a " + "full, single-pass). No effect on rotation data."); auto *limitRes = new QCheckBox("High-resolution limit", this); limitRes->setChecked(scaling_.GetHighResolutionLimit_A().has_value()); auto *highRes = new NumberLineEdit(0.3f, 5.0f, scaling_.GetHighResolutionLimit_A().value_or(2.0), 1, "Å", this); highRes->setEnabled(limitRes->isChecked()); // Stills partiality-uncertainty merge term: adds a systematic sigma ~c*(1-partiality)* on partials, - // so strong low-partiality partials are not over-trusted. Pairs with "Stills partiality" (Bragg); the - // library auto-gates it to strong/medium data. 0 = off; ~2.5 recommended. + // so strong low-partiality partials are not over-trusted. Relevant once partials exist (partiality + // post-refinement on, the default); the library auto-gates it to strong/medium data. 0 = off; ~2.5 rec. const double part_unc = scaling_.GetPartialityUncertaintyCoeff(); auto *partUncertain = new QCheckBox("Partiality uncertainty", this); partUncertain->setChecked(part_unc > 0.0); partUncertain->setToolTip("Stills: add a systematic merge σ ~c·(1−partiality)·⟨I⟩ to partials so strong " - "low-partiality partials are not over-trusted. Use with \"Stills partiality\"; " - "auto-gated to strong/medium data. ~2.5 recommended; unchecked = off."); + "low-partiality partials are not over-trusted. Relevant with partiality " + "post-refinement (the default); auto-gated to strong/medium data. ~2.5 " + "recommended; unchecked = off."); auto *partUncertainCoeff = new NumberLineEdit(0.1f, 10.0f, part_unc > 0.0 ? part_unc : 2.5, 1, "", this); partUncertainCoeff->setEnabled(partUncertain->isChecked()); @@ -533,6 +532,7 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { form->addRow("", refineB); form->addRow("", corrections); form->addRow("", modulation); + form->addRow("", partRefine); // Compact, and aligned with the checkboxes above: the limit checkbox + value sit together in the // field column (not as a row label, which would indent it differently). auto *resRow = new QHBoxLayout(); @@ -551,6 +551,7 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { scaling_.RefineB(refineB->isChecked()); scaling_.CorrectionSurfaces(corrections->isChecked()); scaling_.StillsModulation(modulation->isChecked()); + scaling_.StillsPartialityRefine(partRefine->isChecked()); scaling_.HighResolutionLimit_A(limitRes->isChecked() ? std::optional(highRes->value()) : std::nullopt); scaling_.PartialityUncertaintyCoeff(partUncertain->isChecked() ? partUncertainCoeff->value() : 0.0); @@ -560,6 +561,7 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { connect(refineB, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(modulation, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); + connect(partRefine, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(limitRes, &QCheckBox::toggled, this, [emitScaling, highRes](bool on) { highRes->setEnabled(on); emitScaling(); }); connect(highRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); }); -- 2.54.0 From 9a8c946555aa0d79c9ae484e70dbd65985c941a4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 23 Jul 2026 17:23:19 +0200 Subject: [PATCH 005/295] Add self-calibrating adaptive spot detection for offline stills The offline CPU spot finder marks a pixel strong when it clears a fixed photon count AND a local-window SNR. The fixed photon floor forces per-dataset tuning: its sweet spot tracks the background level (weak sets want a low threshold, strong or high-background sets a high one) and the usable window is narrow, so users hand-tune --spot-threshold/--spot-sigma per dataset. Add an opt-in --adaptive-spots mode (AdaptiveSpotFinderCPU) that replaces the fixed floor with a per-resolution-ring threshold derived from each image's own noise. Per ring it computes a peak-excluded background mean and sigma (one plain pass + two sigma-clip passes over the assembled photon image, binned by the azimuthal-integration ring index) and sets thr = max( PoissonTail(mean, p), mean + z * sqrt(sigma^2 + read^2) ) with p = false_pixels_per_frame / n_pixels the single portable knob (default 100) and z = Phi^-1(1 - p). The Poisson arm is the correct significance where the background is countable (it carries the sqrt(mean) shot noise, so a bright low-resolution ring gets a high threshold); the read-noise-floored Gaussian arm keeps the threshold physical where the background vanishes (empty high-resolution rings), without which those rings flood. read is a detector-level constant, not a per-dataset knob. Both arms are needed: Poisson alone floods near-zero background, Gaussian alone drops the shot-noise term and under-thresholds bright rings. One --adaptive-spots setting then adapts across a wide range of serial datasets with no per-dataset threshold, matching or beating hand-tuned thresholds and the peakfinder8/xgandalf reference on both weak large-cell and strong serial data, with equal merged R-free. The finder runs on the CPU (offline/viewer path) and reads the host image, which the GPU pipeline already keeps in sync, so it works in either build. The default (non-adaptive) path and the online/FPGA path are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- image_analysis/MXAnalysisWithoutFPGA.cpp | 6 +- image_analysis/MXAnalysisWithoutFPGA.h | 5 + .../spot_finding/AdaptiveSpotFinderCPU.cpp | 198 ++++++++++++++++++ .../spot_finding/AdaptiveSpotFinderCPU.h | 45 ++++ image_analysis/spot_finding/CMakeLists.txt | 2 + .../spot_finding/SpotFindingSettings.h | 8 + rugnux/RugnuxCommandLine.cpp | 2 + rugnux/rugnux_cli.cpp | 19 ++ 8 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp create mode 100644 image_analysis/spot_finding/AdaptiveSpotFinderCPU.h diff --git a/image_analysis/MXAnalysisWithoutFPGA.cpp b/image_analysis/MXAnalysisWithoutFPGA.cpp index 537c3493..bd256424 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.cpp +++ b/image_analysis/MXAnalysisWithoutFPGA.cpp @@ -61,6 +61,7 @@ MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_exp roi = std::make_unique(experiment, stream); } #endif + adaptiveSpotFinder = std::make_unique(integration); } void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, @@ -97,7 +98,10 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, UpdateMaskResolution(spot_finding_settings); const auto spot_finding_start_time = std::chrono::steady_clock::now(); - const std::vector spots = spotFinder->Run(*preprocessor_buffer, spot_finding_settings, mask_resolution); + ImageSpotFinder &finder = spot_finding_settings.adaptive_threshold + ? static_cast(*adaptiveSpotFinder) + : *spotFinder; + const std::vector spots = finder.Run(*preprocessor_buffer, spot_finding_settings, mask_resolution); SpotAnalyze(experiment, spot_finding_settings, spots, output); const auto spot_finding_end_time = std::chrono::steady_clock::now(); output.spot_finding_time_s = std::chrono::duration(spot_finding_end_time - spot_finding_start_time).count(); diff --git a/image_analysis/MXAnalysisWithoutFPGA.h b/image_analysis/MXAnalysisWithoutFPGA.h index 16cd0560..1b40f2f4 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.h +++ b/image_analysis/MXAnalysisWithoutFPGA.h @@ -13,6 +13,7 @@ #include "bragg_prediction/BraggPrediction.h" #include "bragg_integration/BraggIntegrationEngine.h" #include "spot_finding/ImageSpotFinder.h" +#include "spot_finding/AdaptiveSpotFinderCPU.h" #include "indexing/IndexerThreadPool.h" #include "azint/AzIntEngine.h" #include "roi/ROIIntegration.h" @@ -37,6 +38,10 @@ class MXAnalysisWithoutFPGA { std::unique_ptr azint; std::unique_ptr roi; std::unique_ptr spotFinder; + // Self-calibrating CPU finder, used when spot settings request adaptive detection. Kept alongside + // the default finder because the choice arrives with the per-image settings, not at construction. + // It reads the host preprocessed image (populated on the GPU path too), so it works in either build. + std::unique_ptr adaptiveSpotFinder; IndexAndRefine &indexer; std::unique_ptr prediction; std::unique_ptr bragg_engine; diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp new file mode 100644 index 00000000..ef4eece4 --- /dev/null +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include +#include +#include + +#include "AdaptiveSpotFinderCPU.h" + +namespace { + +// Number of background pixels a ring needs before its own statistics are trusted; sparser rings +// (detector corners, heavily masked, innermost) fall back to the whole-frame background. +constexpr int64_t MIN_RING_PIXELS = 40; + +// Inverse standard-normal CDF (Acklam's rational approximation, ~1e-9 accuracy). Only called once +// per frame, so accuracy over speed. +double NormalQuantile(double p) { + if (p <= 0.0) return -40.0; + if (p >= 1.0) return 40.0; + static const double a[] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, + 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00}; + static const double b[] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, + 6.680131188771972e+01, -1.328068155288572e+01}; + static const double c[] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, + -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00}; + static const double d[] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, + 3.754408661907416e+00}; + const double plow = 0.02425, phigh = 1.0 - 0.02425; + if (p < plow) { + double q = std::sqrt(-2.0 * std::log(p)); + return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / + ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); + } else if (p <= phigh) { + double q = p - 0.5, r = q*q; + return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / + (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0); + } else { + double q = std::sqrt(-2.0 * std::log(1.0 - p)); + return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / + ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); + } +} + +// Smallest integer count whose Poisson(mu) upper tail P(X >= k) <= p. This is the correct +// significance floor while the background is countable (it carries the sqrt(mu) shot-noise +// implicitly, so a bright low-resolution ring gets a high threshold). It DEGENERATES at mu -> 0 +// (a single photon on a zero background is "significant"), which is why it is max'd with a +// read-noise-floored Gaussian arm by the caller. Short-circuits to Gaussian for large mu. +float PoissonThreshold(double mu, double p, double z) { + if (mu > 50.0) + return static_cast(mu + z * std::sqrt(mu)); + if (mu < 1e-6) mu = 1e-6; + const double target = 1.0 - p; + double pmf = std::exp(-mu); + double cdf = pmf; + int k = 0; + while (cdf < target && k < 1000) { + ++k; + pmf *= mu / k; + cdf += pmf; + } + return static_cast(k + 1); +} + +} // namespace + +AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &in_mapping) + : ImageSpotFinder(static_cast(in_mapping.GetWidth()), + static_cast(in_mapping.GetHeight())), + mapping(in_mapping) { + const size_t nbins = mapping.GetBinNumber(); + ring_sum.assign(nbins, 0.0); + ring_sum2.assign(nbins, 0.0); + ring_cnt.assign(nbins, 0); + ring_mean.assign(nbins, 0.0f); + ring_sigma.assign(nbins, 0.0f); + ring_thr.assign(nbins, 0.0f); +} + +// Accumulate per-ring mean/variance from the raw (photon) image. clip_k <= 0 -> use every valid +// pixel (first pass); clip_k > 0 -> keep only pixels within clip_k sigma of the current ring mean, +// which removes the Bragg peaks from the background estimate. +void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k) { + const auto &pixel_to_bin = mapping.GetPixelToBin(); + const size_t nbins = ring_sum.size(); + const size_t npix = static_cast(width) * height; + + std::fill(ring_sum.begin(), ring_sum.end(), 0.0); + std::fill(ring_sum2.begin(), ring_sum2.end(), 0.0); + std::fill(ring_cnt.begin(), ring_cnt.end(), 0); + + for (size_t pxl = 0; pxl < npix; ++pxl) { + const int32_t v = image[pxl]; + if (v == INT32_MIN || v == INT32_MAX) continue; // bad / saturated + const uint16_t b = pixel_to_bin[pxl]; + if (b >= nbins) continue; // masked / out of range (UINT16_MAX) + if (clip_k > 0.0f) { + const float lo = ring_mean[b] - clip_k * ring_sigma[b]; + const float hi = ring_mean[b] + clip_k * ring_sigma[b]; + if (v < lo || v > hi) continue; // exclude peaks / outliers + } + ring_sum[b] += v; + ring_sum2[b] += static_cast(v) * v; + ring_cnt[b] += 1; + } + + for (size_t b = 0; b < nbins; ++b) { + if (ring_cnt[b] > 0) { + const double m = ring_sum[b] / ring_cnt[b]; + const double var = std::max(0.0, ring_sum2[b] / ring_cnt[b] - m * m); + ring_mean[b] = static_cast(m); + ring_sigma[b] = static_cast(std::sqrt(var)); + } + } +} + +std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) { + const auto &pixel_to_bin = mapping.GetPixelToBin(); + const size_t nbins = ring_sum.size(); + const size_t npix = static_cast(width) * height; + + // --- Stage A: robust per-ring background (one plain pass + two sigma-clip passes) --- + AccumulateRings(image, 0.0f); + AccumulateRings(image, 3.0f); + AccumulateRings(image, 3.0f); + + // --- Stage B: per-ring threshold from the single portable knob E (false pixels / frame) --- + int64_t n_total = 0; + double g_sum = 0.0, g_sum2 = 0.0; + for (size_t b = 0; b < nbins; ++b) { + n_total += ring_cnt[b]; + g_sum += ring_sum[b]; + g_sum2 += ring_sum2[b]; + } + if (n_total == 0) + return {}; + + const double E = std::max(1.0f, settings.false_pixels_per_frame); + double p = E / static_cast(n_total); + p = std::min(std::max(p, 1e-9), 0.1); + const float z = static_cast(NormalQuantile(1.0 - p)); + + // A ring's threshold is background mean + z sigmas. sigma combines the ring's own (peak-excluded) + // scatter with an excess-noise floor READ: near-zero-background rings scatter MORE than pure + // Poisson (charge sharing / read noise / occasional spurious low counts), so a per-ring sigma + // alone collapses toward zero on empty high-resolution rings and the threshold would flood. READ + // is a detector-level photon-scale constant (the same for every dataset -- it is NOT the + // per-dataset knob), so the operating point still self-calibrates through mean and sigma while + // staying physical where the background vanishes. + const float READ = 1.0f; + auto ring_threshold = [&](float mean, float sigma) { + // Poisson significance (correct where the background is countable) floored by a + // read-noise-aware Gaussian arm (which alone survives mean -> 0, where Poisson degenerates + // to "one photon is significant" and would flood the empty high-resolution rings). + const float gauss = mean + z * std::sqrt(sigma * sigma + READ * READ); + const float poisson = PoissonThreshold(mean, static_cast(p), static_cast(z)); + return std::max(gauss, poisson); + }; + + // whole-frame fallback background for rings too sparse to trust on their own + const double g_mean = g_sum / n_total; + const double g_sigma = std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * g_mean)); + const float g_thr = ring_threshold(static_cast(g_mean), static_cast(g_sigma)); + + for (size_t b = 0; b < nbins; ++b) + ring_thr[b] = (ring_cnt[b] < MIN_RING_PIXELS) ? g_thr : ring_threshold(ring_mean[b], ring_sigma[b]); + + // --- Stage C: flag strong pixels into the bit buffer (value >= ring threshold) --- + for (size_t i = 0; i < OutputSize(); ++i) + output_buffer[i] = 0; + + std::bitset<32> out = 0; + for (size_t pxl = 0; pxl < npix; ++pxl) { + const int32_t v = image[pxl]; + const uint16_t b = pixel_to_bin[pxl]; + bool strong = false; + if (v == INT32_MAX) + strong = true; + else if (v != INT32_MIN && b < nbins && v >= ring_thr[b]) + strong = true; + + const int32_t bit = pxl % 32; + if (strong) + out.set(bit); + if (bit == 31) { + output_buffer[pxl / 32] = out.to_ulong(); + out.reset(); + } + } + if (npix % 32 != 0) + output_buffer[OutputSize() - 1] = out.to_ulong(); + + // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- + return ExtractSpots(image, settings, res_mask); +} diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h new file mode 100644 index 00000000..d75e937b --- /dev/null +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "ImageSpotFinder.h" +#include "SpotFindingSettings.h" +#include "../../common/AzimuthalIntegrationMapping.h" + +// Self-calibrating strong-pixel detector for the offline (rugnux/viewer) path. +// +// The classic finder (ImageSpotFinderCPU) marks a pixel strong when it clears a *fixed* photon +// count AND a local-box SNR. The fixed photon floor is what forces per-dataset tuning: it must sit +// above the background (wants high) yet not bury weak spots (wants low), and the background level +// differs per dataset, so the sweet spot is narrow (~12 photons on one serial-stills set, ~5 on a +// weaker one). +// +// Here the floor is replaced by a per-resolution-ring threshold derived from a single portable +// number: E = the expected count of noise pixels tolerated per frame (default ~100). For a ring +// whose (peak-excluded) background mean is mu, the threshold is the smallest count whose Poisson +// upper tail is <= p = E / N_pixels, max'd with a Gaussian arm mu + z*sigma to absorb read/flat-field +// excess. Because it is set from the image's own noise, the SAME E lands at ~12 photons on the first +// set and ~5 on the weaker one with no user input. Detection then is simply value > ring_threshold, +// fed to the same connected-component builder as the classic finder. +class AdaptiveSpotFinderCPU : public ImageSpotFinder { + const AzimuthalIntegrationMapping &mapping; + + // per-ring scratch, sized to the mapping's bin count + std::vector ring_sum; + std::vector ring_sum2; + std::vector ring_cnt; + std::vector ring_mean; + std::vector ring_sigma; + std::vector ring_thr; + + void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k); + +public: + explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); + std::vector Run(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) override; +}; diff --git a/image_analysis/spot_finding/CMakeLists.txt b/image_analysis/spot_finding/CMakeLists.txt index 44bd6ca1..0a94ca7d 100644 --- a/image_analysis/spot_finding/CMakeLists.txt +++ b/image_analysis/spot_finding/CMakeLists.txt @@ -1,6 +1,8 @@ ADD_LIBRARY(JFJochSpotFinding STATIC ImageSpotFinderCPU.cpp ImageSpotFinderCPU.h + AdaptiveSpotFinderCPU.cpp + AdaptiveSpotFinderCPU.h SpotUtils.cpp SpotUtils.h SpotFindingSettings.h diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index 126d6442..b69b7072 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -23,4 +23,12 @@ struct SpotFindingSettings { bool indexing = true; bool quick_integration = true; + + // Self-calibrating detection (offline/rugnux path): when true, the fixed photon_count_threshold is + // replaced by a per-resolution-ring threshold set from the image's own noise (see + // AdaptiveSpotFinderCPU), so the same setting adapts across datasets with no per-dataset tuning. + // false_pixels_per_frame is the one portable knob: the expected number of noise pixels tolerated + // per frame (the threshold's operating point), ~100 for a multi-megapixel detector. + bool adaptive_threshold = false; + float false_pixels_per_frame = 100.0f; }; diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 4d8450a6..0b6b6bfa 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -85,6 +85,8 @@ std::string RugnuxCommandLine(const ProcessConfig &config, const auto &sf = config.spot_finding; add("--spot-sigma", num(sf.signal_to_noise_threshold)); add("--spot-threshold", std::to_string(sf.photon_count_threshold)); + if (sf.adaptive_threshold) + add("--spot-false-pixels", num(sf.false_pixels_per_frame)); add("--spot-high-resolution", num(sf.high_resolution_limit)); add("--max-spots", std::to_string(experiment.GetMaxSpotCount())); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index eafc7967..6d526ce6 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -65,6 +65,8 @@ void print_usage() { std::cout << " --spot-sigma Noise sigma level for spot finding (default: 3.0)" << std::endl; std::cout << " --spot-threshold Photon count threshold for spot finding (default: 10)" << std::endl; std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; + std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; + std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; @@ -144,6 +146,8 @@ enum { OPT_SPOT_SIGMA = 1000, OPT_SPOT_THRESHOLD, OPT_MIN_PIX_PER_SPOT, + OPT_ADAPTIVE_SPOTS, + OPT_SPOT_FALSE_PIXELS, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, OPT_MAX_SPOTS, @@ -254,6 +258,8 @@ static option long_options[] = { {"spot-sigma", required_argument, nullptr, OPT_SPOT_SIGMA}, {"spot-threshold", required_argument, nullptr, OPT_SPOT_THRESHOLD}, {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, + {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, + {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, {"max-spots", required_argument, nullptr, OPT_MAX_SPOTS}, @@ -518,6 +524,8 @@ int main(int argc, char **argv) { float sigma_spot_finding = 3.0; int64_t photon_count_threshold_spot_finding = 10; int64_t min_pix_per_spot = 2; + bool adaptive_spots = false; + float false_pixels_per_frame = 100.0f; bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; @@ -748,6 +756,15 @@ int main(int argc, char **argv) { min_pix_per_spot = parse_number_arg(optarg, "--min-pix-per-spot", logger, 1); logger.Info("Minimum pixels per spot set to {:d}", min_pix_per_spot); break; + case OPT_ADAPTIVE_SPOTS: + adaptive_spots = true; + logger.Info("Adaptive (self-calibrating) spot detection enabled"); + break; + case OPT_SPOT_FALSE_PIXELS: + false_pixels_per_frame = parse_number_arg(optarg, "--spot-false-pixels", logger, 1.0f); + adaptive_spots = true; + logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame); + break; case OPT_SPOT_LOW_RESOLUTION: d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); @@ -1487,6 +1504,8 @@ int main(int argc, char **argv) { spot_settings.signal_to_noise_threshold = sigma_spot_finding; spot_settings.photon_count_threshold = photon_count_threshold_spot_finding; spot_settings.min_pix_per_spot = min_pix_per_spot; + spot_settings.adaptive_threshold = adaptive_spots; + spot_settings.false_pixels_per_frame = false_pixels_per_frame; if (d_min_spot_finding > 0.0f) spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) -- 2.54.0 From 6de03bc4436b66c6efe1e9002783dc78134539ba Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 23 Jul 2026 17:38:18 +0200 Subject: [PATCH 006/295] Add threshold-free persistence variant of adaptive spot detection Add --persistence-spots, a second parameter-free detector alongside --adaptive-spots. Instead of a hard per-ring threshold it builds the noise-normalised image z = (I - ring_mean) / sqrt(ring_sigma^2 + read^2) (same per-ring background as the hard variant) and scores every intensity maximum by its 0-D topological persistence: sweeping the height from high to low, each maximum is born and, when its basin meets a taller one at a saddle, dies with persistence = birth - saddle, in sigma. A lone noise spike merges into the background almost immediately (persistence ~1 sigma); a real peak stands many sigma proud. Emitting maxima whose persistence clears the same z(E) significance bar needs no photon threshold and no min-pix, and it deblends touching peaks (each keeps its own maximum). Implemented with the same union-find idiom as the connected-component labeller. On serial stills this auto-adapts with no per-dataset tuning like --adaptive-spots, finding fewer but cleaner (deblended) spots; the hard-threshold variant remains more sensitive on the very weakest data. Both share the per-ring background and read-noise floor. comp_of is allocated lazily so the default and hard-adaptive paths pay nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spot_finding/AdaptiveSpotFinderCPU.cpp | 117 ++++++++++++++++++ .../spot_finding/AdaptiveSpotFinderCPU.h | 5 + .../spot_finding/SpotFindingSettings.h | 7 ++ rugnux/rugnux_cli.cpp | 10 ++ 4 files changed, 139 insertions(+) diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index ef4eece4..06479019 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "AdaptiveSpotFinderCPU.h" @@ -76,6 +77,7 @@ AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping & ring_mean.assign(nbins, 0.0f); ring_sigma.assign(nbins, 0.0f); ring_thr.assign(nbins, 0.0f); + // comp_of is allocated lazily (only the persistence variant needs it). } // Accumulate per-ring mean/variance from the raw (photon) image. clip_k <= 0 -> use every valid @@ -118,6 +120,9 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) { + if (settings.spot_persistence) + return RunPersistence(image, settings, res_mask); + const auto &pixel_to_bin = mapping.GetPixelToBin(); const size_t nbins = ring_sum.size(); const size_t npix = static_cast(width) * height; @@ -196,3 +201,115 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- return ExtractSpots(image, settings, res_mask); } + +// Threshold-free variant. Build a noise-normalised image z = (I - ring_mean)/sqrt(ring_sigma^2+READ^2) +// (same per-ring background and read-noise floor as the hard variant), then score every intensity +// maximum by its 0-D topological persistence: sweep the height from high to low, each maximum is +// "born" and, when its basin meets a taller one at a saddle, "dies" with persistence = birth - saddle +// (in sigma units). A lone noise spike merges into the sea almost immediately (persistence ~1); a real +// peak stands many sigma proud. Emitting maxima with persistence >= z(E) needs no photon threshold and +// no min-pix, and deblends touching peaks (each keeps its own maximum). Union-find, same idiom as the +// classic connected-component labeller. This is the offline/rugnux "soft" alternative to the hard cut. +std::vector AdaptiveSpotFinderCPU::RunPersistence(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) { + const auto &pixel_to_bin = mapping.GetPixelToBin(); + const size_t nbins = ring_sum.size(); + const size_t npix = static_cast(width) * height; + if (comp_of.size() != npix) + comp_of.assign(npix, -1); + + AccumulateRings(image, 0.0f); + AccumulateRings(image, 3.0f); + AccumulateRings(image, 3.0f); + + int64_t n_total = 0; + double g_sum = 0.0, g_sum2 = 0.0; + for (size_t b = 0; b < nbins; ++b) { n_total += ring_cnt[b]; g_sum += ring_sum[b]; g_sum2 += ring_sum2[b]; } + if (n_total == 0) + return {}; + + const double E = std::max(1.0f, settings.false_pixels_per_frame); + double p = std::min(std::max(E / static_cast(n_total), 1e-9), 0.1); + const float z = static_cast(NormalQuantile(1.0 - p)); + const float READ = 1.0f; + const float PERS_THR = z; // a maximum must stand z sigmas above its saddle to be a spot + const float Z_FLOOR = 2.0f; // loose landscape floor: a compute bound, not a detection threshold + const float g_mean = static_cast(g_sum / n_total); + const float g_sigma = static_cast(std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * (double)g_mean))); + + auto mu_of = [&](uint16_t b) { return ring_cnt[b] < MIN_RING_PIXELS ? g_mean : ring_mean[b]; }; + auto se_of = [&](uint16_t b) { + const float s = ring_cnt[b] < MIN_RING_PIXELS ? g_sigma : ring_sigma[b]; + return std::sqrt(s * s + READ * READ); + }; + + // Candidate pixels: everything above a loose noise-normalised floor (in-resolution, not masked). + struct Cand { float z; int32_t pxl; }; + std::vector cand; + for (size_t pxl = 0; pxl < npix; ++pxl) { + if (res_mask[pxl]) continue; + const int32_t v = image[pxl]; + if (v == INT32_MIN) continue; + const uint16_t b = pixel_to_bin[pxl]; + if (b >= nbins) continue; + const float zz = (v == INT32_MAX) ? 1.0e6f : (v - mu_of(b)) / se_of(b); + if (zz > Z_FLOOR) cand.push_back({zz, static_cast(pxl)}); + } + if (cand.empty()) + return {}; + std::sort(cand.begin(), cand.end(), [](const Cand &a, const Cand &b) { return a.z > b.z; }); + + // Union-find over candidates, processed highest first. parent/birth/pers are per-component. + std::vector parent; + std::vector birth, pers; + parent.reserve(cand.size()); birth.reserve(cand.size()); pers.reserve(cand.size()); + auto find = [&](int32_t c) { while (parent[c] != c) { parent[c] = parent[parent[c]]; c = parent[c]; } return c; }; + + for (const auto &cd : cand) { + const int32_t pxl = cd.pxl; + const int32_t col = pxl % width, row = pxl / width; + int32_t roots[8]; int nr = 0; + for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) { + if (dr == 0 && dc == 0) continue; + const int rr = row + dr, cc = col + dc; + if (rr < 0 || rr >= height || cc < 0 || cc >= width) continue; + const int32_t np = rr * width + cc; + if (comp_of[np] < 0) continue; // neighbour not yet processed (lower z) + const int32_t r = find(comp_of[np]); + bool dup = false; + for (int i = 0; i < nr; ++i) if (roots[i] == r) dup = true; + if (!dup && nr < 8) roots[nr++] = r; + } + if (nr == 0) { // new maximum born + const int32_t c = static_cast(parent.size()); + parent.push_back(c); birth.push_back(cd.z); pers.push_back(1.0e9f); + comp_of[pxl] = c; + } else { + int32_t tall = roots[0]; + for (int i = 1; i < nr; ++i) if (birth[roots[i]] > birth[tall]) tall = roots[i]; + for (int i = 0; i < nr; ++i) + if (roots[i] != tall) { pers[roots[i]] = birth[roots[i]] - cd.z; parent[roots[i]] = tall; } + comp_of[pxl] = tall; + } + } + for (size_t c = 0; c < parent.size(); ++c) + if (parent[c] == static_cast(c)) pers[c] = birth[c] - Z_FLOOR; // survivors + + // One spot per surviving maximum whose persistence clears the significance bar. + std::unordered_map spots; + for (const auto &cd : cand) { + const int32_t root = find(comp_of[cd.pxl]); + if (pers[root] < PERS_THR) continue; + const int32_t pxl = cd.pxl; + const int64_t v = (image[pxl] == INT32_MAX) ? 65535 : image[pxl]; + spots[root].AddPixel(pxl % width, pxl / width, v); + } + + for (const auto &cd : cand) comp_of[cd.pxl] = -1; // reset for the next frame (touched pixels only) + + std::vector out; + out.reserve(spots.size()); + for (auto &kv : spots) out.push_back(kv.second); + return out; +} diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index d75e937b..c5e046d9 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -34,8 +34,13 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { std::vector ring_mean; std::vector ring_sigma; std::vector ring_thr; + std::vector comp_of; // per-pixel component id for the persistence variant (-1 = unset) void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k); + // Threshold-free variant: 0-D topological persistence on the noise-normalised image. + std::vector RunPersistence(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask); public: explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index b69b7072..43852c70 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -31,4 +31,11 @@ struct SpotFindingSettings { // per frame (the threshold's operating point), ~100 for a multi-megapixel detector. bool adaptive_threshold = false; float false_pixels_per_frame = 100.0f; + + // Threshold-free variant of the adaptive detector (implies adaptive_threshold): instead of a hard + // per-ring cut, score each intensity maximum by its topological persistence (how many sigma it + // stands above the saddle joining it to higher ground) on the noise-normalised image. Persistence + // is a graded per-spot significance and needs no min-pix (a lone noise spike has ~1 sigma + // persistence; a real peak much more). See AdaptiveSpotFinderCPU::RunPersistence. + bool spot_persistence = false; }; diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 6d526ce6..b43984d0 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -67,6 +67,7 @@ void print_usage() { std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; + std::cout << " --persistence-spots Threshold-free variant of --adaptive-spots: score each intensity maximum by its topological persistence (no hard cut, no min-pix)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; @@ -148,6 +149,7 @@ enum { OPT_MIN_PIX_PER_SPOT, OPT_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, + OPT_PERSISTENCE_SPOTS, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, OPT_MAX_SPOTS, @@ -260,6 +262,7 @@ static option long_options[] = { {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, + {"persistence-spots", no_argument, nullptr, OPT_PERSISTENCE_SPOTS}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, {"max-spots", required_argument, nullptr, OPT_MAX_SPOTS}, @@ -526,6 +529,7 @@ int main(int argc, char **argv) { int64_t min_pix_per_spot = 2; bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; + bool persistence_spots = false; bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; @@ -765,6 +769,11 @@ int main(int argc, char **argv) { adaptive_spots = true; logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame); break; + case OPT_PERSISTENCE_SPOTS: + adaptive_spots = true; + persistence_spots = true; + logger.Info("Threshold-free (topological-persistence) spot detection enabled"); + break; case OPT_SPOT_LOW_RESOLUTION: d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); @@ -1506,6 +1515,7 @@ int main(int argc, char **argv) { spot_settings.min_pix_per_spot = min_pix_per_spot; spot_settings.adaptive_threshold = adaptive_spots; spot_settings.false_pixels_per_frame = false_pixels_per_frame; + spot_settings.spot_persistence = persistence_spots; if (d_min_spot_finding > 0.0f) spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) -- 2.54.0 From 7c5bedfd74e8ae20dd3e3144d70d50ae0dfcb029 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 23 Jul 2026 19:53:55 +0200 Subject: [PATCH 007/295] Add soft per-spot quality weighting for adaptive spot detection Add --soft-weight (implies --adaptive-spots): give every detected spot a continuous quality weight in (0,1] and keep the highest-weight spots rather than the brightest, so a deliberately loose detector self-cleans -- bright ice / salt / jet blobs and single-pixel noise no longer evict faint clean Bragg spots from the max-spots cut. The weight is a product of dimensionless gates (AdaptiveSpotFinderCPU::ApplyWeights, computed against the per-ring background the adaptive finder already builds): a logistic ramp in the spot's SNR and a soft size band (rises from one pixel, plateaus, falls for oversized ice/salt/streak blobs). It carries on DiffractionSpot -> SpotToSave and is consumed by FilterSpotsByCount, which ranks by {non-ice, weight, intensity} when requested and by intensity otherwise, so the classic and FPGA paths are unchanged. Honest result: on the serial-stills battery this is index-rate-NEUTRAL. The weighted ranking only changes the outcome when the spot count exceeds the max-spots cap and the weight disagrees with intensity in a way that affects indexing; the adaptive detectors already produce clean spot lists and the weak sets sit under the cap, so re-ranking is a wash there (and a wash, not a regression, on the one set that floods). Its intended benefit -- robustness to ice/jet-contaminated frames and to a loosened detector -- is not exercised by this battery; kept opt-in as the substrate for that. Co-Authored-By: Claude Opus 4.8 (1M context) --- common/DiffractionSpot.cpp | 1 + common/DiffractionSpot.h | 3 ++ common/SpotToSave.h | 1 + .../spot_finding/AdaptiveSpotFinderCPU.cpp | 31 ++++++++++++++++++- .../spot_finding/AdaptiveSpotFinderCPU.h | 4 +++ .../spot_finding/SpotFindingSettings.h | 7 +++++ image_analysis/spot_finding/SpotUtils.cpp | 16 ++++++---- image_analysis/spot_finding/SpotUtils.h | 2 +- rugnux/rugnux_cli.cpp | 10 ++++++ 9 files changed, 67 insertions(+), 8 deletions(-) diff --git a/common/DiffractionSpot.cpp b/common/DiffractionSpot.cpp index 5e6a8857..9d65d668 100644 --- a/common/DiffractionSpot.cpp +++ b/common/DiffractionSpot.cpp @@ -85,6 +85,7 @@ std::optional DiffractionSpot::Export(const DiffractionGeometry &geo .lattice = -1, .image = image_num, .d_A = d, + .weight = weight, .ice_ring = false, .indexed = false }; diff --git a/common/DiffractionSpot.h b/common/DiffractionSpot.h index 2614b4c9..43c00ea1 100644 --- a/common/DiffractionSpot.h +++ b/common/DiffractionSpot.h @@ -15,7 +15,10 @@ class DiffractionSpot { int64_t pixel_count = 0; int64_t photons = 0; // total photon count int64_t max_photons = INT64_MIN; // maximum number of counts per pixel in the spot + float weight = 1.0f; // soft quality weight in (0,1]; 1 = "no opinion" (default) public: + void SetWeight(float w) { weight = w; } + float Weight() const { return weight; } DiffractionSpot() = default; DiffractionSpot(uint32_t col, uint32_t line, int64_t photons); DiffractionSpot(const SpotToSave &save); diff --git a/common/SpotToSave.h b/common/SpotToSave.h index d866acbc..491f7a96 100644 --- a/common/SpotToSave.h +++ b/common/SpotToSave.h @@ -16,6 +16,7 @@ struct SpotToSave { int64_t h = 0, k = 0, l= 0; float d_A = 0.0; float dist_ewald_sphere = 0.0; + float weight = 1.0f; // soft quality weight in (0,1]; used to rank the kept spots (see --soft-weight) bool ice_ring = false; bool indexed = false; diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index 06479019..5ce1b687 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -199,7 +199,34 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB output_buffer[OutputSize() - 1] = out.to_ulong(); // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- - return ExtractSpots(image, settings, res_mask); + auto spots = ExtractSpots(image, settings, res_mask); + if (settings.soft_weight) + ApplyWeights(spots); + return spots; +} + +void AdaptiveSpotFinderCPU::ApplyWeights(std::vector &spots) const { + const auto &pixel_to_bin = mapping.GetPixelToBin(); + const size_t nbins = ring_mean.size(); + const float READ = 1.0f; + for (auto &s : spots) { + const Coord c = s.RawCoord(); // flux-weighted centroid (col, row) + const int col = std::min(std::max(static_cast(std::lround(c.x)), 0), width - 1); + const int row = std::min(std::max(static_cast(std::lround(c.y)), 0), height - 1); + const uint16_t b = pixel_to_bin[static_cast(row) * width + col]; + const float mu = (b < nbins) ? ring_mean[b] : 0.0f; + const double N = std::max(s.PixelCount(), 1); + const double tot = std::max(s.Count(), 0); + const double signal = tot - N * mu; + const double noise = std::sqrt(std::max(1.0, tot + N * static_cast(READ) * READ)); + const double snr = signal / noise; + // Dimensionless gates (sigma, pixels): high SNR -> keep; a reasonable pixel count -> keep, while + // 1-pixel noise (rising edge) and oversized ice/salt/streak blobs (falling edge) -> ~0. + const float w_snr = 1.0f / (1.0f + std::exp(-static_cast(snr - 4.0) / 1.5f)); + const float w_size = (1.0f / (1.0f + std::exp(-(static_cast(N) - 1.5f) / 0.7f))) + * (1.0f / (1.0f + std::exp(-(40.0f - static_cast(N)) / 8.0f))); + s.SetWeight(std::min(std::max(w_snr * w_size, 0.0f), 1.0f)); + } } // Threshold-free variant. Build a noise-normalised image z = (I - ring_mean)/sqrt(ring_sigma^2+READ^2) @@ -311,5 +338,7 @@ std::vector AdaptiveSpotFinderCPU::RunPersistence(const ImagePr std::vector out; out.reserve(spots.size()); for (auto &kv : spots) out.push_back(kv.second); + if (settings.soft_weight) + ApplyWeights(out); return out; } diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index c5e046d9..039653a4 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -41,6 +41,10 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { std::vector RunPersistence(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask); + // Assign each spot a soft quality weight (SNR against the per-ring background x a soft size band), + // consumed downstream to keep the best spots rather than the brightest. Needs the ring background, + // so it must run after AccumulateRings. + void ApplyWeights(std::vector &spots) const; public: explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index 43852c70..970df0ae 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -38,4 +38,11 @@ struct SpotFindingSettings { // is a graded per-spot significance and needs no min-pix (a lone noise spike has ~1 sigma // persistence; a real peak much more). See AdaptiveSpotFinderCPU::RunPersistence. bool spot_persistence = false; + + // Soft per-spot weighting (implies adaptive detection): assign every detected spot a continuous + // quality weight from its SNR (against the per-ring background) and a soft size band (too few or + // too many pixels -> low), then keep the highest-WEIGHT spots rather than the brightest. Lets a + // deliberately loose detector self-clean -- bright ice/salt/jet blobs and single-pixel noise no + // longer evict faint clean Bragg spots. See AdaptiveSpotFinderCPU::ApplyWeights. + bool soft_weight = false; }; diff --git a/image_analysis/spot_finding/SpotUtils.cpp b/image_analysis/spot_finding/SpotUtils.cpp index d272fb3f..da082741 100644 --- a/image_analysis/spot_finding/SpotUtils.cpp +++ b/image_analysis/spot_finding/SpotUtils.cpp @@ -37,15 +37,19 @@ void MarkIceRings(std::vector &spots, float tolerance_q_recipA) { } } -void FilterSpotsByCount(std::vector &input, int64_t count) { +void FilterSpotsByCount(std::vector &input, int64_t count, bool by_weight) { size_t output_size = std::min(input.size(), count); std::ranges::partial_sort(input, input.begin() + output_size, std::ranges::less{}, // comparator on the projected key - [](const SpotToSave &s) { - // projection: key to compare by - return std::tuple{s.ice_ring, -s.intensity}; - // false < true → non-ice first; negate intensity → higher first + [by_weight](const SpotToSave &s) { + // projection: key to compare by. non-ice first (false < true), then + // by soft quality weight (higher first) when requested -- so a loose + // detector's bright junk cannot evict faint clean Bragg -- else by + // raw intensity. Intensity is the tie-breaker under the weight. + if (by_weight) + return std::tuple{s.ice_ring, -s.weight, -s.intensity}; + return std::tuple{s.ice_ring, 0.0f, -s.intensity}; }); input.resize(output_size); } @@ -152,7 +156,7 @@ void SpotAnalyze(const DiffractionExperiment &experiment, output.resolution_estimate = GetResolution(spots_out); - FilterSpotsByCount(spots_out, experiment.GetMaxSpotCount()); + FilterSpotsByCount(spots_out, experiment.GetMaxSpotCount(), spot_finding_settings.soft_weight); output.spots = spots_out; } diff --git a/image_analysis/spot_finding/SpotUtils.h b/image_analysis/spot_finding/SpotUtils.h index 9ba458be..f85441d5 100644 --- a/image_analysis/spot_finding/SpotUtils.h +++ b/image_analysis/spot_finding/SpotUtils.h @@ -18,7 +18,7 @@ void CountSpots(DataMessage &msg, void MarkIceRings(std::vector &spots, float tolerance_q_recipA); -void FilterSpotsByCount(std::vector &input, int64_t count); +void FilterSpotsByCount(std::vector &input, int64_t count, bool by_weight = false); void FilterSpuriousHighResolutionSpots(std::vector &spots, float threshold); // Ignore high res. spots if there is a gap in (1/d) between two spots of dist_threshold (default: 0.25 A^-1) diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index b43984d0..73d3f2fd 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -68,6 +68,7 @@ void print_usage() { std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; std::cout << " --persistence-spots Threshold-free variant of --adaptive-spots: score each intensity maximum by its topological persistence (no hard cut, no min-pix)" << std::endl; + std::cout << " --soft-weight With --adaptive-spots: weight each spot by SNR + a soft size band and keep the highest-quality (not brightest) spots, so bright ice/salt/noise cannot crowd out faint Bragg" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; @@ -150,6 +151,7 @@ enum { OPT_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, OPT_PERSISTENCE_SPOTS, + OPT_SOFT_WEIGHT, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, OPT_MAX_SPOTS, @@ -263,6 +265,7 @@ static option long_options[] = { {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, {"persistence-spots", no_argument, nullptr, OPT_PERSISTENCE_SPOTS}, + {"soft-weight", no_argument, nullptr, OPT_SOFT_WEIGHT}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, {"max-spots", required_argument, nullptr, OPT_MAX_SPOTS}, @@ -530,6 +533,7 @@ int main(int argc, char **argv) { bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; bool persistence_spots = false; + bool soft_weight_flag = false; bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; @@ -774,6 +778,11 @@ int main(int argc, char **argv) { persistence_spots = true; logger.Info("Threshold-free (topological-persistence) spot detection enabled"); break; + case OPT_SOFT_WEIGHT: + adaptive_spots = true; + soft_weight_flag = true; + logger.Info("Soft per-spot weighting enabled (keep highest-quality spots, not brightest)"); + break; case OPT_SPOT_LOW_RESOLUTION: d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); @@ -1516,6 +1525,7 @@ int main(int argc, char **argv) { spot_settings.adaptive_threshold = adaptive_spots; spot_settings.false_pixels_per_frame = false_pixels_per_frame; spot_settings.spot_persistence = persistence_spots; + spot_settings.soft_weight = soft_weight_flag; if (d_min_spot_finding > 0.0f) spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) -- 2.54.0 From 17eed80ff9d7cfd1b3fdef9118964e097f7e5251 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 23 Jul 2026 23:53:40 +0200 Subject: [PATCH 008/295] Seed still indexing with the strongest spots; refine with all On flooded or noisy still frames (weakly-diffracting detectors, XFEL background, ice) the full spot list derails the known-cell indexer: its many spurious peaks compete with the true reflections for the search, so genuinely diffracting frames fail to index. Seed the indexer with a few spot-count subsets (30 / 80 / all) and keep the lattice that explains the largest FRACTION of its own seed -- a lean, clean seed that a good lattice indexes almost fully beats a flooded seed it fits only in small part. This auto-selects a lean seed on noisy frames and the full seed where the extra spots are real signal, with no per-dataset setting. Geometry refinement and integration still use the full spot list (the orientation refiner filters spots by lattice match, so the flood is ignored while high-resolution spots are kept), so resolution is preserved. Costs at most ~3 indexer calls per frame, only on frames that do not index on the first, lean seed. Lifts the indexed-crystal yield on mildly-flooded synchrotron serial data with no regression elsewhere. Stills only; the rotation indexing path is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- image_analysis/IndexAndRefine.cpp | 52 +++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/image_analysis/IndexAndRefine.cpp b/image_analysis/IndexAndRefine.cpp index fa986b2c..9b503036 100644 --- a/image_analysis/IndexAndRefine.cpp +++ b/image_analysis/IndexAndRefine.cpp @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only +#include #include #include "IndexAndRefine.h" @@ -94,17 +95,50 @@ IndexAndRefine::IndexingOutcome IndexAndRefine::DetermineLatticeAndSymmetry(Data IndexingOutcome outcome(experiment); - // Convert input spots to reciprocal space - std::vector recip; - recip.reserve(msg.spots.size()); - for (const auto &i: msg.spots) { - if (index_ice_rings || !i.ice_ring) - recip.push_back(i.ReciprocalCoord(geom_)); + // Seed the indexer with the strongest few spots first and escalate to more only if that fails. + // On flooded / noisy frames (XFEL, ice) a lean high-quality seed finds the lattice far more + // reliably than the full spot list, whose many spurious peaks derail the search; on clean frames + // the lean seed already works, so nothing is lost. The FULL spot list is still used for geometry + // refinement and integration downstream, so higher-resolution accuracy is preserved. Cost is + // ~1 indexer call on frames that index cleanly, up to 3 only on the hard ones. msg.spots is + // already ordered non-ice-first, strongest-first (FilterSpotsByCount), so the prefix IS the seed. + const float idx_tol = experiment.GetIndexingSettings().GetTolerance(); + const float idx_tol_sq = idx_tol * idx_tol; + IndexerResult indexer_result; + bool any_executed = false; + float best_frac = -1.0f; + for (size_t seed_cap : {size_t{30}, size_t{80}, std::numeric_limits::max()}) { + std::vector recip; + recip.reserve(std::min(seed_cap, msg.spots.size())); + for (const auto &i: msg.spots) { + if (index_ice_rings || !i.ice_ring) { + recip.push_back(i.ReciprocalCoord(geom_)); + if (recip.size() >= seed_cap) + break; + } + } + auto res = indexer_->Run(experiment, recip); + any_executed |= res.executed; + if (!res.lattice.empty()) { + // Keep the seed the lattice explains the largest FRACTION of: a lean clean seed a good + // lattice indexes almost fully beats a flooded seed it fits only in small part. This + // auto-selects the lean seed on noisy frames (XFEL) and the full seed where the extra spots + // are real signal (weak synchrotron) -- no per-dataset setting. + const Coord a = res.lattice[0].Vec0(), b = res.lattice[0].Vec1(), c = res.lattice[0].Vec2(); + int n = 0; + for (const auto &q : recip) { + const float hf = q * a, kf = q * b, lf = q * c; + const float dh = hf - std::round(hf), dk = kf - std::round(kf), dl = lf - std::round(lf); + if (dh * dh + dk * dk + dl * dl < idx_tol_sq) ++n; + } + const float frac = recip.empty() ? 0.0f : static_cast(n) / recip.size(); + if (frac > best_frac) { best_frac = frac; indexer_result = std::move(res); } + } + if (recip.size() < seed_cap) // already fed every available spot; a larger cap won't add any + break; } - auto indexer_result = indexer_->Run(experiment, recip); - - if (indexer_result.executed) + if (any_executed) msg.indexing_result = false; if (!indexer_result.lattice.empty()) { -- 2.54.0 From 503bd367387c1d2affb831fc8f1b8995ea23e868 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 24 Jul 2026 08:41:48 +0200 Subject: [PATCH 009/295] Apply --min-image-cc merge-consistency filter on the stills merge path The per-image CC-to-reference filter (--min-image-cc) was only honoured on the rotation merge; the stills merge added every crystal unconditionally. Extend it to stills so the flag is meaningful there too: on flooded frames that produce many spurious lattices (large-cell serial data), the crystals whose per-image CC to the reference falls below the limit are dropped, keeping only the coherent ones in the merge. Opt-in and default-off (limit 0 -> the loop passes cc_filter=false and the merge is bit-identical to before), so no existing behaviour changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- rugnux/Rugnux.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 172a6d76..3d4c4bce 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -1008,8 +1008,13 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b merge_engine.ErrorModelB(), merge_engine.ErrorModelB() > 0 ? 1.0 / merge_engine.ErrorModelB() : 0.0, merge_engine.ErrorModelChi2()); + // Merge-consistency filter: on floods with many spurious lattices (e.g. XFEL large cells) + // most indexed crystals do not correlate with the true structure; --min-image-cc drops the + // crystals whose per-image CC to the reference is below the limit, so the merge keeps only + // the coherent (real) ones. + const bool cc_filter = !for_search && experiment_.GetScalingSettings().GetMinCCForImage() > 0.0; for (size_t i = 0; i < merge_input.size(); ++i) - merge_engine.AddImage(merge_input[i], static_cast(i)); + merge_engine.AddImage(merge_input[i], static_cast(i), cc_filter); ScaleMergeResult out; out.merged = merge_engine.ExportReflections(); -- 2.54.0 From ca7cbe206a9b64239e1b31fcbecfb81a1f041bd0 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 24 Jul 2026 11:12:22 +0200 Subject: [PATCH 010/295] Add opt-in local-SNR spot gate and acceptance-fraction knob (serial stills) Two opt-in tools for weak serial-stills tuning; both default-off, so the default pipeline is bit-identical (verified: a serial-stills reference run reproduces HEAD's 7.85% indexing rate exactly). --local-snr (AdaptiveSpotFinderCPU::FilterByLocalSNR): after the loose per-ring adaptive threshold builds connected-component spots, drop any spot that does not stand this many sigmas above its OWN LOCAL background (robust median/MAD of a square annulus), not just the azimuthal ring mean. On structured-background (XFEL) frames the ring mean underestimates the local diffuse level in some sectors, so the ring threshold floods; a real Bragg peak still stands many local sigmas proud. Validated on XFEL stills to separate real peaks from flood at the pixel level (real median local-SNR ~70 vs flood ~2.6; SNR>=5 keeps ~99.8% of real peaks, ~14% of flood). GPU-portable (a per-spot local reduction). NOTE: on the current serial-stills battery it is index-rate/CC1/2 neutral -- the flood that survives as CC clusters overlaps weak-real spots, and only lattice-fit separates those -- but it is the correct tool for genuinely floody data (ice/jet/loosened detector) and the right substrate for the online FPGA path. --min-indexed-fraction : exposes the previously hardcoded 0.20 minimum indexed-spot fraction (AnalyzeIndexing) as a per-run setting. Lowering it admits weaker/sparser crystals; on flooded XFEL data the extra lattices are spurious (pair with --min-image-cc to gate them), on clean synchrotron data there are no marginal frames so it is a no-op -- useful as a gating-experiment primitive. Co-Authored-By: Claude Opus 4.8 (1M context) --- common/IndexingSettings.cpp | 9 ++++ common/IndexingSettings.h | 6 +++ image_analysis/indexing/AnalyzeIndexing.cpp | 3 +- image_analysis/indexing/AnalyzeIndexing.h | 2 - .../spot_finding/AdaptiveSpotFinderCPU.cpp | 53 +++++++++++++++++++ .../spot_finding/AdaptiveSpotFinderCPU.h | 5 ++ .../spot_finding/SpotFindingSettings.h | 10 ++++ rugnux/RugnuxCommandLine.cpp | 2 + rugnux/rugnux_cli.cpp | 20 +++++++ 9 files changed, 107 insertions(+), 3 deletions(-) diff --git a/common/IndexingSettings.cpp b/common/IndexingSettings.cpp index 2de46bcb..1400f85c 100644 --- a/common/IndexingSettings.cpp +++ b/common/IndexingSettings.cpp @@ -28,6 +28,15 @@ int64_t IndexingSettings::GetViableCellMinSpots() const { return viable_cell_min_spots; } +IndexingSettings &IndexingSettings::MinIndexedSpotFraction(float input) { + min_indexed_spot_fraction = input; + return *this; +} + +float IndexingSettings::GetMinIndexedSpotFraction() const { + return min_indexed_spot_fraction; +} + IndexingSettings &IndexingSettings::Algorithm(IndexingAlgorithmEnum input) { switch (input) { case IndexingAlgorithmEnum::Auto: diff --git a/common/IndexingSettings.h b/common/IndexingSettings.h index 258e15ba..2872fc6d 100644 --- a/common/IndexingSettings.h +++ b/common/IndexingSettings.h @@ -24,6 +24,10 @@ class IndexingSettings { static constexpr float unit_cell_angle_tolerance_deg = 5.0; // degree int64_t indexing_threads = 4; int64_t viable_cell_min_spots = 9; + // Minimum fraction of the in-resolution spots a candidate lattice must index to be accepted. + // Lowering it admits weaker/sparser crystals (more real ones on flooded XFEL frames, but also + // more spurious lattices that a downstream merge-consistency gate must remove). + float min_indexed_spot_fraction = 0.20f; int64_t max_extra_lattices = 2; @@ -39,6 +43,7 @@ public: IndexingSettings(); IndexingSettings& ViableCellMinSpots(int64_t input); + IndexingSettings& MinIndexedSpotFraction(float input); IndexingSettings& Algorithm(IndexingAlgorithmEnum input); IndexingSettings& FFT_MaxUnitCell_A(float input); IndexingSettings& FFT_MinUnitCell_A(float input); @@ -58,6 +63,7 @@ public: IndexingSettings& MaxExtraLattices(int64_t input); [[nodiscard]] int64_t GetViableCellMinSpots() const; + [[nodiscard]] float GetMinIndexedSpotFraction() const; [[nodiscard]] IndexingAlgorithmEnum GetAlgorithm() const; [[nodiscard]] GeomRefinementAlgorithmEnum GetGeomRefinementAlgorithm() const; [[nodiscard]] float GetFFT_MaxUnitCell_A() const; diff --git a/image_analysis/indexing/AnalyzeIndexing.cpp b/image_analysis/indexing/AnalyzeIndexing.cpp index 1a4be08d..bc1661b8 100644 --- a/image_analysis/indexing/AnalyzeIndexing.cpp +++ b/image_analysis/indexing/AnalyzeIndexing.cpp @@ -375,7 +375,8 @@ bool AnalyzeIndexing(DataMessage &message, int64_t indexing_lattice_count = 0; bool outcome = false; - if (nspots_indexed >= viable_cell_min_spots && nspots_indexed >= std::lround(min_percentage_spots * nspots_ref)) { + const float min_frac = experiment.GetIndexingSettings().GetMinIndexedSpotFraction(); + if (nspots_indexed >= viable_cell_min_spots && nspots_indexed >= std::lround(min_frac * nspots_ref)) { auto uc = latt.GetUnitCell(); if (ok(uc.a) && ok(uc.b) && ok(uc.c) && ok(uc.alpha) && ok(uc.beta) && ok(uc.gamma)) { message.indexing_result = true; diff --git a/image_analysis/indexing/AnalyzeIndexing.h b/image_analysis/indexing/AnalyzeIndexing.h index a2b347be..1e612343 100644 --- a/image_analysis/indexing/AnalyzeIndexing.h +++ b/image_analysis/indexing/AnalyzeIndexing.h @@ -7,8 +7,6 @@ #include "../../common/DiffractionExperiment.h" #include "../../common/JFJochMessages.h" -constexpr static float min_percentage_spots = 0.20f; - bool AnalyzeIndexing(DataMessage &message, const DiffractionExperiment &experiment, const CrystalLattice &latt, diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index 5ce1b687..e5430ad5 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -200,11 +200,62 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- auto spots = ExtractSpots(image, settings, res_mask); + if (settings.local_snr > 0.0f) + FilterByLocalSNR(image, spots, settings.local_snr); if (settings.soft_weight) ApplyWeights(spots); return spots; } +// Reject spots that do not stand out against their LOCAL background. The loose per-ring threshold +// keeps ~100% of real Bragg peaks but, on structured-background (XFEL) frames, also floods with +// spurious pixels: the azimuthal ring mean underestimates the diffuse level in some sectors, so a +// locally-high background pixel clears it. A real peak stands many sigmas above the background in +// its IMMEDIATE neighbourhood, a flood pixel does not. For each spot the background mean and scatter +// are measured from a square annulus around its centroid (robust median / MAD, so a neighbouring +// peak in the annulus cannot bias it), and the spot is kept only if its integrated signal exceeds +// k local sigmas. k is in sigma units -- self-calibrating, no photon threshold. +void AdaptiveSpotFinderCPU::FilterByLocalSNR(const ImagePreprocessorBuffer &image, + std::vector &spots, float k) const { + constexpr int RIN = 3; // half-width of the excluded core (7x7) + constexpr int ROUT = 6; // half-width of the background annulus (13x13) + std::vector bg; + bg.reserve((2 * ROUT + 1) * (2 * ROUT + 1)); + std::vector kept; + kept.reserve(spots.size()); + for (const auto &s : spots) { + const Coord c = s.RawCoord(); + const int col = static_cast(std::lround(c.x)); + const int row = static_cast(std::lround(c.y)); + bg.clear(); + for (int dr = -ROUT; dr <= ROUT; ++dr) { + const int rr = row + dr; + if (rr < 0 || rr >= height) continue; + for (int dc = -ROUT; dc <= ROUT; ++dc) { + if (std::max(std::abs(dr), std::abs(dc)) <= RIN) continue; // skip the peak core + const int cc = col + dc; + if (cc < 0 || cc >= width) continue; + const int32_t v = image[static_cast(rr) * width + cc]; + if (v == INT32_MIN || v == INT32_MAX) continue; // masked / saturated + bg.push_back(static_cast(v)); + } + } + if (bg.size() < 8) { kept.push_back(s); continue; } // too few bg pixels to judge + const size_t mid = bg.size() / 2; + std::nth_element(bg.begin(), bg.begin() + mid, bg.end()); + const float bg_med = bg[mid]; + for (auto &v : bg) v = std::fabs(v - bg_med); + std::nth_element(bg.begin(), bg.begin() + mid, bg.end()); + const float sigma = std::max(1.4826f * bg[mid], 1.0f); + const double npix = static_cast(std::max(s.PixelCount(), 1)); + const double signal = static_cast(s.Count()) - bg_med * npix; + const double snr = signal / (sigma * std::sqrt(npix)); + if (snr >= static_cast(k)) + kept.push_back(s); + } + spots.swap(kept); +} + void AdaptiveSpotFinderCPU::ApplyWeights(std::vector &spots) const { const auto &pixel_to_bin = mapping.GetPixelToBin(); const size_t nbins = ring_mean.size(); @@ -338,6 +389,8 @@ std::vector AdaptiveSpotFinderCPU::RunPersistence(const ImagePr std::vector out; out.reserve(spots.size()); for (auto &kv : spots) out.push_back(kv.second); + if (settings.local_snr > 0.0f) + FilterByLocalSNR(image, out, settings.local_snr); if (settings.soft_weight) ApplyWeights(out); return out; diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index 039653a4..a62dd792 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -45,6 +45,11 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { // consumed downstream to keep the best spots rather than the brightest. Needs the ring background, // so it must run after AccumulateRings. void ApplyWeights(std::vector &spots) const; + // Drop spots that do not stand k sigmas above their own LOCAL (annulus) background. The + // discriminator the per-ring threshold lacks on structured-background frames; see the header + // comment on SpotFindingSettings::local_snr. + void FilterByLocalSNR(const ImagePreprocessorBuffer &image, + std::vector &spots, float k) const; public: explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index 970df0ae..f79c5354 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -45,4 +45,14 @@ struct SpotFindingSettings { // deliberately loose detector self-clean -- bright ice/salt/jet blobs and single-pixel noise no // longer evict faint clean Bragg spots. See AdaptiveSpotFinderCPU::ApplyWeights. bool soft_weight = false; + + // Local-background SNR gate (implies adaptive detection): after connected-component spots are + // built from the loose per-ring threshold, reject any spot whose integrated signal does not + // clear this many sigmas above its OWN LOCAL background (measured from a robust annulus around + // it), not just the azimuthal ring mean. On structured-background (XFEL) frames the ring mean + // underestimates the local diffuse level in some sectors, so the ring threshold floods with + // spurious pixels; a real Bragg peak still stands many local sigmas proud, so this recovers the + // clean spot list a global threshold cannot. In sigma units -> self-calibrating, no photon + // threshold. 0 disables. See AdaptiveSpotFinderCPU::FilterByLocalSNR. + float local_snr = 0.0f; }; diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 0b6b6bfa..7c258ddb 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -87,6 +87,8 @@ std::string RugnuxCommandLine(const ProcessConfig &config, add("--spot-threshold", std::to_string(sf.photon_count_threshold)); if (sf.adaptive_threshold) add("--spot-false-pixels", num(sf.false_pixels_per_frame)); + if (sf.local_snr > 0.0f) + add("--local-snr", num(sf.local_snr)); add("--spot-high-resolution", num(sf.high_resolution_limit)); add("--max-spots", std::to_string(experiment.GetMaxSpotCount())); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 73d3f2fd..624cd654 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -69,6 +69,8 @@ void print_usage() { std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; std::cout << " --persistence-spots Threshold-free variant of --adaptive-spots: score each intensity maximum by its topological persistence (no hard cut, no min-pix)" << std::endl; std::cout << " --soft-weight With --adaptive-spots: weight each spot by SNR + a soft size band and keep the highest-quality (not brightest) spots, so bright ice/salt/noise cannot crowd out faint Bragg" << std::endl; + std::cout << " --local-snr With --adaptive-spots: keep only spots standing this many sigma above their own LOCAL background (annulus), removing structured-background (XFEL) flood the ring threshold lets through (e.g. 5; implies --adaptive-spots)" << std::endl; + std::cout << " --min-indexed-fraction Minimum fraction of in-resolution spots a lattice must index to be accepted (default 0.20); lower to admit weaker/sparser crystals (pair with --min-image-cc to gate the extra spurious ones)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; @@ -152,6 +154,8 @@ enum { OPT_SPOT_FALSE_PIXELS, OPT_PERSISTENCE_SPOTS, OPT_SOFT_WEIGHT, + OPT_LOCAL_SNR, + OPT_MIN_INDEXED_FRACTION, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, OPT_MAX_SPOTS, @@ -266,6 +270,8 @@ static option long_options[] = { {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, {"persistence-spots", no_argument, nullptr, OPT_PERSISTENCE_SPOTS}, {"soft-weight", no_argument, nullptr, OPT_SOFT_WEIGHT}, + {"local-snr", required_argument, nullptr, OPT_LOCAL_SNR}, + {"min-indexed-fraction", required_argument, nullptr, OPT_MIN_INDEXED_FRACTION}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, {"max-spots", required_argument, nullptr, OPT_MAX_SPOTS}, @@ -534,6 +540,8 @@ int main(int argc, char **argv) { float false_pixels_per_frame = 100.0f; bool persistence_spots = false; bool soft_weight_flag = false; + float local_snr = 0.0f; + std::optional min_indexed_fraction; bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; @@ -783,6 +791,15 @@ int main(int argc, char **argv) { soft_weight_flag = true; logger.Info("Soft per-spot weighting enabled (keep highest-quality spots, not brightest)"); break; + case OPT_LOCAL_SNR: + local_snr = parse_number_arg(optarg, "--local-snr", logger, 0.0f); + adaptive_spots = true; + logger.Info("Local-background SNR gate enabled: keep spots >= {:.1f} sigma above their local background", local_snr); + break; + case OPT_MIN_INDEXED_FRACTION: + min_indexed_fraction = parse_number_arg(optarg, "--min-indexed-fraction", logger, 0.0f); + logger.Info("Minimum indexed-spot fraction for acceptance set to {:.2f}", min_indexed_fraction.value()); + break; case OPT_SPOT_LOW_RESOLUTION: d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); @@ -1421,6 +1438,8 @@ int main(int argc, char **argv) { if (rotation_indexing_range.has_value()) indexing_settings.RotationIndexingMinAngularRange_deg(rotation_indexing_range.value()); indexing_settings.GeomRefinementAlgorithm(refinement_algorithm); + if (min_indexed_fraction.has_value()) + indexing_settings.MinIndexedSpotFraction(min_indexed_fraction.value()); experiment.ImportIndexingSettings(indexing_settings); // --detect-ice-rings[=on|off] overrides the value carried in from the dataset (HDF5MetadataSource @@ -1526,6 +1545,7 @@ int main(int argc, char **argv) { spot_settings.false_pixels_per_frame = false_pixels_per_frame; spot_settings.spot_persistence = persistence_spots; spot_settings.soft_weight = soft_weight_flag; + spot_settings.local_snr = local_snr; if (d_min_spot_finding > 0.0f) spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) -- 2.54.0 From ecf79af0184b022743aa3ac7e1e0538f703d9a84 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 25 Jul 2026 12:41:44 +0200 Subject: [PATCH 011/295] Remove threshold-free persistence spot-detection variant Drops --persistence-spots and AdaptiveSpotFinderCPU::RunPersistence (the 0-D topological-persistence detector added in 5a33b0743). It was a research variant that never beat the hard-threshold adaptive detector on a CC1/2 basis and is a GPU dead-end (global candidate sort + union-find), so it is not a production path. The hard-threshold --adaptive-spots detector is unaffected. Co-Authored-By: Claude Opus 4.8 --- .../spot_finding/AdaptiveSpotFinderCPU.cpp | 120 ------------------ .../spot_finding/AdaptiveSpotFinderCPU.h | 5 - .../spot_finding/SpotFindingSettings.h | 7 - rugnux/rugnux_cli.cpp | 10 -- 4 files changed, 142 deletions(-) diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index e5430ad5..e891604d 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -77,7 +77,6 @@ AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping & ring_mean.assign(nbins, 0.0f); ring_sigma.assign(nbins, 0.0f); ring_thr.assign(nbins, 0.0f); - // comp_of is allocated lazily (only the persistence variant needs it). } // Accumulate per-ring mean/variance from the raw (photon) image. clip_k <= 0 -> use every valid @@ -120,9 +119,6 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) { - if (settings.spot_persistence) - return RunPersistence(image, settings, res_mask); - const auto &pixel_to_bin = mapping.GetPixelToBin(); const size_t nbins = ring_sum.size(); const size_t npix = static_cast(width) * height; @@ -279,119 +275,3 @@ void AdaptiveSpotFinderCPU::ApplyWeights(std::vector &spots) co s.SetWeight(std::min(std::max(w_snr * w_size, 0.0f), 1.0f)); } } - -// Threshold-free variant. Build a noise-normalised image z = (I - ring_mean)/sqrt(ring_sigma^2+READ^2) -// (same per-ring background and read-noise floor as the hard variant), then score every intensity -// maximum by its 0-D topological persistence: sweep the height from high to low, each maximum is -// "born" and, when its basin meets a taller one at a saddle, "dies" with persistence = birth - saddle -// (in sigma units). A lone noise spike merges into the sea almost immediately (persistence ~1); a real -// peak stands many sigma proud. Emitting maxima with persistence >= z(E) needs no photon threshold and -// no min-pix, and deblends touching peaks (each keeps its own maximum). Union-find, same idiom as the -// classic connected-component labeller. This is the offline/rugnux "soft" alternative to the hard cut. -std::vector AdaptiveSpotFinderCPU::RunPersistence(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) { - const auto &pixel_to_bin = mapping.GetPixelToBin(); - const size_t nbins = ring_sum.size(); - const size_t npix = static_cast(width) * height; - if (comp_of.size() != npix) - comp_of.assign(npix, -1); - - AccumulateRings(image, 0.0f); - AccumulateRings(image, 3.0f); - AccumulateRings(image, 3.0f); - - int64_t n_total = 0; - double g_sum = 0.0, g_sum2 = 0.0; - for (size_t b = 0; b < nbins; ++b) { n_total += ring_cnt[b]; g_sum += ring_sum[b]; g_sum2 += ring_sum2[b]; } - if (n_total == 0) - return {}; - - const double E = std::max(1.0f, settings.false_pixels_per_frame); - double p = std::min(std::max(E / static_cast(n_total), 1e-9), 0.1); - const float z = static_cast(NormalQuantile(1.0 - p)); - const float READ = 1.0f; - const float PERS_THR = z; // a maximum must stand z sigmas above its saddle to be a spot - const float Z_FLOOR = 2.0f; // loose landscape floor: a compute bound, not a detection threshold - const float g_mean = static_cast(g_sum / n_total); - const float g_sigma = static_cast(std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * (double)g_mean))); - - auto mu_of = [&](uint16_t b) { return ring_cnt[b] < MIN_RING_PIXELS ? g_mean : ring_mean[b]; }; - auto se_of = [&](uint16_t b) { - const float s = ring_cnt[b] < MIN_RING_PIXELS ? g_sigma : ring_sigma[b]; - return std::sqrt(s * s + READ * READ); - }; - - // Candidate pixels: everything above a loose noise-normalised floor (in-resolution, not masked). - struct Cand { float z; int32_t pxl; }; - std::vector cand; - for (size_t pxl = 0; pxl < npix; ++pxl) { - if (res_mask[pxl]) continue; - const int32_t v = image[pxl]; - if (v == INT32_MIN) continue; - const uint16_t b = pixel_to_bin[pxl]; - if (b >= nbins) continue; - const float zz = (v == INT32_MAX) ? 1.0e6f : (v - mu_of(b)) / se_of(b); - if (zz > Z_FLOOR) cand.push_back({zz, static_cast(pxl)}); - } - if (cand.empty()) - return {}; - std::sort(cand.begin(), cand.end(), [](const Cand &a, const Cand &b) { return a.z > b.z; }); - - // Union-find over candidates, processed highest first. parent/birth/pers are per-component. - std::vector parent; - std::vector birth, pers; - parent.reserve(cand.size()); birth.reserve(cand.size()); pers.reserve(cand.size()); - auto find = [&](int32_t c) { while (parent[c] != c) { parent[c] = parent[parent[c]]; c = parent[c]; } return c; }; - - for (const auto &cd : cand) { - const int32_t pxl = cd.pxl; - const int32_t col = pxl % width, row = pxl / width; - int32_t roots[8]; int nr = 0; - for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) { - if (dr == 0 && dc == 0) continue; - const int rr = row + dr, cc = col + dc; - if (rr < 0 || rr >= height || cc < 0 || cc >= width) continue; - const int32_t np = rr * width + cc; - if (comp_of[np] < 0) continue; // neighbour not yet processed (lower z) - const int32_t r = find(comp_of[np]); - bool dup = false; - for (int i = 0; i < nr; ++i) if (roots[i] == r) dup = true; - if (!dup && nr < 8) roots[nr++] = r; - } - if (nr == 0) { // new maximum born - const int32_t c = static_cast(parent.size()); - parent.push_back(c); birth.push_back(cd.z); pers.push_back(1.0e9f); - comp_of[pxl] = c; - } else { - int32_t tall = roots[0]; - for (int i = 1; i < nr; ++i) if (birth[roots[i]] > birth[tall]) tall = roots[i]; - for (int i = 0; i < nr; ++i) - if (roots[i] != tall) { pers[roots[i]] = birth[roots[i]] - cd.z; parent[roots[i]] = tall; } - comp_of[pxl] = tall; - } - } - for (size_t c = 0; c < parent.size(); ++c) - if (parent[c] == static_cast(c)) pers[c] = birth[c] - Z_FLOOR; // survivors - - // One spot per surviving maximum whose persistence clears the significance bar. - std::unordered_map spots; - for (const auto &cd : cand) { - const int32_t root = find(comp_of[cd.pxl]); - if (pers[root] < PERS_THR) continue; - const int32_t pxl = cd.pxl; - const int64_t v = (image[pxl] == INT32_MAX) ? 65535 : image[pxl]; - spots[root].AddPixel(pxl % width, pxl / width, v); - } - - for (const auto &cd : cand) comp_of[cd.pxl] = -1; // reset for the next frame (touched pixels only) - - std::vector out; - out.reserve(spots.size()); - for (auto &kv : spots) out.push_back(kv.second); - if (settings.local_snr > 0.0f) - FilterByLocalSNR(image, out, settings.local_snr); - if (settings.soft_weight) - ApplyWeights(out); - return out; -} diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index a62dd792..0faf7590 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -34,13 +34,8 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { std::vector ring_mean; std::vector ring_sigma; std::vector ring_thr; - std::vector comp_of; // per-pixel component id for the persistence variant (-1 = unset) void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k); - // Threshold-free variant: 0-D topological persistence on the noise-normalised image. - std::vector RunPersistence(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask); // Assign each spot a soft quality weight (SNR against the per-ring background x a soft size band), // consumed downstream to keep the best spots rather than the brightest. Needs the ring background, // so it must run after AccumulateRings. diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index f79c5354..4d1dd099 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -32,13 +32,6 @@ struct SpotFindingSettings { bool adaptive_threshold = false; float false_pixels_per_frame = 100.0f; - // Threshold-free variant of the adaptive detector (implies adaptive_threshold): instead of a hard - // per-ring cut, score each intensity maximum by its topological persistence (how many sigma it - // stands above the saddle joining it to higher ground) on the noise-normalised image. Persistence - // is a graded per-spot significance and needs no min-pix (a lone noise spike has ~1 sigma - // persistence; a real peak much more). See AdaptiveSpotFinderCPU::RunPersistence. - bool spot_persistence = false; - // Soft per-spot weighting (implies adaptive detection): assign every detected spot a continuous // quality weight from its SNR (against the per-ring background) and a soft size band (too few or // too many pixels -> low), then keep the highest-WEIGHT spots rather than the brightest. Lets a diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 624cd654..182d41b2 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -67,7 +67,6 @@ void print_usage() { std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; - std::cout << " --persistence-spots Threshold-free variant of --adaptive-spots: score each intensity maximum by its topological persistence (no hard cut, no min-pix)" << std::endl; std::cout << " --soft-weight With --adaptive-spots: weight each spot by SNR + a soft size band and keep the highest-quality (not brightest) spots, so bright ice/salt/noise cannot crowd out faint Bragg" << std::endl; std::cout << " --local-snr With --adaptive-spots: keep only spots standing this many sigma above their own LOCAL background (annulus), removing structured-background (XFEL) flood the ring threshold lets through (e.g. 5; implies --adaptive-spots)" << std::endl; std::cout << " --min-indexed-fraction Minimum fraction of in-resolution spots a lattice must index to be accepted (default 0.20); lower to admit weaker/sparser crystals (pair with --min-image-cc to gate the extra spurious ones)" << std::endl; @@ -152,7 +151,6 @@ enum { OPT_MIN_PIX_PER_SPOT, OPT_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, - OPT_PERSISTENCE_SPOTS, OPT_SOFT_WEIGHT, OPT_LOCAL_SNR, OPT_MIN_INDEXED_FRACTION, @@ -268,7 +266,6 @@ static option long_options[] = { {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, - {"persistence-spots", no_argument, nullptr, OPT_PERSISTENCE_SPOTS}, {"soft-weight", no_argument, nullptr, OPT_SOFT_WEIGHT}, {"local-snr", required_argument, nullptr, OPT_LOCAL_SNR}, {"min-indexed-fraction", required_argument, nullptr, OPT_MIN_INDEXED_FRACTION}, @@ -538,7 +535,6 @@ int main(int argc, char **argv) { int64_t min_pix_per_spot = 2; bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; - bool persistence_spots = false; bool soft_weight_flag = false; float local_snr = 0.0f; std::optional min_indexed_fraction; @@ -781,11 +777,6 @@ int main(int argc, char **argv) { adaptive_spots = true; logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame); break; - case OPT_PERSISTENCE_SPOTS: - adaptive_spots = true; - persistence_spots = true; - logger.Info("Threshold-free (topological-persistence) spot detection enabled"); - break; case OPT_SOFT_WEIGHT: adaptive_spots = true; soft_weight_flag = true; @@ -1543,7 +1534,6 @@ int main(int argc, char **argv) { spot_settings.min_pix_per_spot = min_pix_per_spot; spot_settings.adaptive_threshold = adaptive_spots; spot_settings.false_pixels_per_frame = false_pixels_per_frame; - spot_settings.spot_persistence = persistence_spots; spot_settings.soft_weight = soft_weight_flag; spot_settings.local_snr = local_snr; if (d_min_spot_finding > 0.0f) -- 2.54.0 From f72b4484e2a253e02de1be6dde9a854e4a0ce272 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 25 Jul 2026 15:05:06 +0200 Subject: [PATCH 012/295] Remove dead per-crystal deltaCChalf rejection Drops the --reject-delta-cchalf flag and MergeOnTheFly::DeltaCChalfReject. The CLI value was parsed but never consumed (the method had no call site), so the flag was already a no-op. Wiring it up and testing against an external reference structure showed it is confirmation bias: on a spurious-crystal flood it raised internal CC1/2 while CCref (correlation to the true structure) fell, and it never improved R_meas. The merge weights are already correct; per-crystal merge-side rejection has no genuine lever here. Co-Authored-By: Claude Opus 4.8 --- image_analysis/scale_merge/Merge.cpp | 101 --------------------------- image_analysis/scale_merge/Merge.h | 6 -- rugnux/rugnux_cli.cpp | 7 -- 3 files changed, 114 deletions(-) diff --git a/image_analysis/scale_merge/Merge.cpp b/image_analysis/scale_merge/Merge.cpp index 0b7ec2cb..82f91431 100644 --- a/image_analysis/scale_merge/Merge.cpp +++ b/image_analysis/scale_merge/Merge.cpp @@ -478,107 +478,6 @@ void MergeOnTheFly::RefineErrorModel(const std::vector &outc error_model_chi2 = chi2.empty() ? 0.0 : median(chi2) / CHI2_1_MEDIAN; } -// Per-crystal CC1/2-delta rejection (CrystFEL deltaCChalf style). Each image is assigned -// to one CC1/2 half, so removing an image only perturbs that half's per-reflection means. -// deltaCChalf_i = CC1/2(all) - CC1/2(without image i): a NEGATIVE value means removing the -// image RAISES CC1/2, i.e. it is inconsistent with the consensus. We flag images whose -// deltaCChalf is a low-side statistical outlier (< mean - nsigma*stddev). Reference-free. -// Two passes over the (retained) outcomes; per-image contributions are re-derived, not -// stored, so memory stays O(unique reflections + images) for full 200k-frame datasets. -std::vector MergeOnTheFly::DeltaCChalfReject(const std::vector &outcomes, - double nsigma) const { - struct Acc { double swI[2] = {0, 0}; double sw[2] = {0, 0}; size_t n[2] = {0, 0}; }; - std::unordered_map acc; - std::vector img_half(outcomes.size(), 0); - - // ---- pass 1: accumulate half-set sums, record each image's half ---- - auto contribution = [&](const Reflection &r, uint64_t &key, double &wI, double &w) -> bool { - if (generator.IsSystematicallyAbsent(r)) return false; - if (r.image_scale_corr <= 0.0 || !std::isfinite(r.image_scale_corr)) return false; - if (!AcceptReflection(r, high_resolution_limit)) return false; - if (r.partiality < min_partiality) return false; - const double I = static_cast(r.I) * r.image_scale_corr; - const double s = static_cast(r.sigma) * r.image_scale_corr; - if (!std::isfinite(I) || !std::isfinite(s) || s <= 0.0) return false; - w = 1.0 / (s * s); - wI = w * I; - key = generator(r).pack(); - return true; - }; - - for (size_t i = 0; i < outcomes.size(); ++i) { - // Same deterministic half-set as the merge (HalfForImage), so deltaCChalf is measured on the - // exact CC1/2 split the statistics report - not an independent, order-dependent RNG draw. - const int half = HalfForImage(static_cast(i)); - img_half[i] = half; - for (const auto &r : outcomes[i].reflections) { - uint64_t key; double wI, w; - if (!contribution(r, key, wI, w)) continue; - auto &a = acc[key]; - a.swI[half] += wI; a.sw[half] += w; a.n[half]++; - } - } - - // ---- baseline Pearson over reflections present in BOTH halves (x=half0, y=half1) ---- - auto pearson = [](double N, double Sx, double Sy, double Sxx, double Syy, double Sxy) -> double { - const double cov = N * Sxy - Sx * Sy; - const double vx = N * Sxx - Sx * Sx, vy = N * Syy - Sy * Sy; - const double den = std::sqrt(vx * vy); - return den > 0.0 ? cov / den : 0.0; - }; - double N = 0, Sx = 0, Sy = 0, Sxx = 0, Syy = 0, Sxy = 0; - for (const auto &kv : acc) { - const auto &a = kv.second; - if (a.n[0] == 0 || a.n[1] == 0) continue; - const double x = a.swI[0] / a.sw[0], y = a.swI[1] / a.sw[1]; - N += 1; Sx += x; Sy += y; Sxx += x * x; Syy += y * y; Sxy += x * y; - } - const double cc_base = pearson(N, Sx, Sy, Sxx, Syy, Sxy); - - // ---- pass 2: leave-one-out deltaCChalf per image ---- - std::vector delta(outcomes.size(), 0.0); - for (size_t i = 0; i < outcomes.size(); ++i) { - const int h = img_half[i]; - // aggregate this image's contributions per reflection key (an image may, rarely, - // touch the same ASU reflection twice) - std::unordered_map> mine; // key -> (sum wI, sum w), count via .first - std::unordered_map mine_n; - for (const auto &r : outcomes[i].reflections) { - uint64_t key; double wI, w; - if (!contribution(r, key, wI, w)) continue; - auto &p = mine[key]; p.first += wI; p.second += w; mine_n[key]++; - } - double n = N, sx = Sx, sy = Sy, sxx = Sxx, syy = Syy, sxy = Sxy; - for (const auto &m : mine) { - const auto &a = acc.at(m.first); - if (a.n[0] == 0 || a.n[1] == 0) continue; // reflection not in CC1/2 - const double x0 = a.swI[0] / a.sw[0], y0 = a.swI[1] / a.sw[1]; - n -= 1; sx -= x0; sy -= y0; sxx -= x0 * x0; syy -= y0 * y0; sxy -= x0 * y0; - const double swI_h = a.swI[h] - m.second.first; - const double sw_h = a.sw[h] - m.second.second; - if (a.n[h] - mine_n[m.first] == 0 || sw_h <= 0.0) continue; // reflection drops half h - const double mean_h = swI_h / sw_h; - const double xnew = (h == 0) ? mean_h : x0; - const double ynew = (h == 1) ? mean_h : y0; - n += 1; sx += xnew; sy += ynew; sxx += xnew * xnew; syy += ynew * ynew; sxy += xnew * ynew; - } - delta[i] = cc_base - pearson(n, sx, sy, sxx, syy, sxy); - } - - // ---- reject low-side outliers: delta < mean - nsigma*stddev ---- - double dm = 0, dv = 0; - for (double d : delta) dm += d; - dm /= std::max(1, delta.size()); - for (double d : delta) dv += (d - dm) * (d - dm); - const double dstd = std::sqrt(dv / std::max(1, delta.size())); - const double cut = dm - nsigma * dstd; - - std::vector reject(outcomes.size(), 0); - for (size_t i = 0; i < outcomes.size(); ++i) - reject[i] = (outcomes[i].reflections.empty() ? 0 : (delta[i] < cut ? 1 : 0)); - return reject; -} - bool MergeOnTheFly::Mask(const IntegrationOutcome &outcome, bool cc_mask) { if (reference_cell) { auto cell = outcome.latt.GetUnitCell(); diff --git a/image_analysis/scale_merge/Merge.h b/image_analysis/scale_merge/Merge.h index fdcd42c4..5b9dc093 100644 --- a/image_analysis/scale_merge/Merge.h +++ b/image_analysis/scale_merge/Merge.h @@ -178,12 +178,6 @@ public: // scaling and before RefineErrorModel/AddImage. Returns the applied held-out gain fraction (0 = no-op). double RefineModulation(std::vector &outcomes); - // Per-crystal CC1/2-delta rejection (CrystFEL deltaCChalf): returns a per-image flag - // marking images whose removal would raise CC1/2 by a low-side outlier amount - // (deltaCChalf < mean - nsigma*stddev). Skip the flagged images when merging. - [[nodiscard]] std::vector DeltaCChalfReject(const std::vector &outcomes, - double nsigma) const; - // d_min_override, when set, is the effective high-resolution limit for the shell table (used for // the automatic resolution cutoff computed by the caller); otherwise the manual // ScalingSettings high-resolution limit stands. The number of shells is ScalingSettings::ReportShellCount. diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 182d41b2..a490bf93 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -113,7 +113,6 @@ void print_usage() { std::cout << " --min-captured-fraction rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl; std::cout << " --mosaicity Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl; std::cout << " --reject-outliers Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl; - std::cout << " --reject-delta-cchalf Per-crystal CC1/2-delta rejection: drop images with deltaCChalf below mean - N*stddev (default: off; e.g. 2.5)" << std::endl; std::cout << " --min-image-cc Per-image CC limit in percent (default: no limit)" << std::endl; std::cout << " --scaling-iterations Number of scaling iterations with no reference data (default: 3)" << std::endl; std::cout << " -z, --reference-mtz Reference MTZ file" << std::endl; @@ -173,7 +172,6 @@ enum { OPT_INTEGRATION_RADIUS, OPT_BACKGROUND_TRIM, OPT_REJECT_OUTLIERS, - OPT_REJECT_DELTA_CCHALF, OPT_REFERENCE_COLUMN, OPT_MODEL, OPT_DUMP_OBSERVATIONS, @@ -290,7 +288,6 @@ static option long_options[] = { {"simple-stills", no_argument, nullptr, OPT_SIMPLE_STILLS}, {"detect-ice-rings", optional_argument, nullptr, OPT_DETECT_ICE_RINGS}, {"reject-outliers", required_argument, nullptr, OPT_REJECT_OUTLIERS}, - {"reject-delta-cchalf", required_argument, nullptr, OPT_REJECT_DELTA_CCHALF}, {nullptr, 0, nullptr, 0} }; @@ -570,7 +567,6 @@ int main(int argc, char **argv) { std::optional integrator_mode; // --integrator boxsum|gaussian|empirical bool simple_stills_flag = false; // --simple-stills: disable the default stills partiality post-refinement std::optional outlier_reject_nsigma; // merge per-observation outlier rejection - std::optional delta_cchalf_nsigma; // per-crystal CC1/2-delta rejection if (argc == 1) { print_usage(); @@ -879,9 +875,6 @@ int main(int argc, char **argv) { case OPT_REJECT_OUTLIERS: outlier_reject_nsigma = parse_double_arg(optarg, "--reject-outliers", logger); break; - case OPT_REJECT_DELTA_CCHALF: - delta_cchalf_nsigma = parse_double_arg(optarg, "--reject-delta-cchalf", logger); - break; case OPT_MIN_IMAGE_CC: min_image_cc = parse_double_arg(optarg, "--min-image-cc", logger); break; -- 2.54.0 From 20bbcb1cd3a9c47bbfcdbf67b02398246caf8c10 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 25 Jul 2026 17:31:35 +0200 Subject: [PATCH 013/295] Remove --soft-weight and --local-snr spot-finder options Both were opt-in adaptive-spot refinements that did not help. Soft per-spot weighting was index-rate neutral across the battery (re-ranking only bites when spots exceed the max-spot cap, which weak serial data does not reach). The local-SNR gate was neutral on index rate and degraded merged CC1/2 on flooded XFEL data. Drops the flags, ApplyWeights/FilterByLocalSNR, the per-spot weight field, and the by-weight FilterSpotsByCount branch (now strongest-first only). --adaptive-spots itself is unchanged. Co-Authored-By: Claude Opus 4.8 --- common/DiffractionSpot.cpp | 1 - common/DiffractionSpot.h | 3 - common/SpotToSave.h | 1 - .../spot_finding/AdaptiveSpotFinderCPU.cpp | 80 +------------------ .../spot_finding/AdaptiveSpotFinderCPU.h | 9 --- .../spot_finding/SpotFindingSettings.h | 17 ---- image_analysis/spot_finding/SpotUtils.cpp | 15 ++-- image_analysis/spot_finding/SpotUtils.h | 2 +- rugnux/RugnuxCommandLine.cpp | 2 - rugnux/rugnux_cli.cpp | 20 ----- 10 files changed, 7 insertions(+), 143 deletions(-) diff --git a/common/DiffractionSpot.cpp b/common/DiffractionSpot.cpp index 9d65d668..5e6a8857 100644 --- a/common/DiffractionSpot.cpp +++ b/common/DiffractionSpot.cpp @@ -85,7 +85,6 @@ std::optional DiffractionSpot::Export(const DiffractionGeometry &geo .lattice = -1, .image = image_num, .d_A = d, - .weight = weight, .ice_ring = false, .indexed = false }; diff --git a/common/DiffractionSpot.h b/common/DiffractionSpot.h index 43c00ea1..2614b4c9 100644 --- a/common/DiffractionSpot.h +++ b/common/DiffractionSpot.h @@ -15,10 +15,7 @@ class DiffractionSpot { int64_t pixel_count = 0; int64_t photons = 0; // total photon count int64_t max_photons = INT64_MIN; // maximum number of counts per pixel in the spot - float weight = 1.0f; // soft quality weight in (0,1]; 1 = "no opinion" (default) public: - void SetWeight(float w) { weight = w; } - float Weight() const { return weight; } DiffractionSpot() = default; DiffractionSpot(uint32_t col, uint32_t line, int64_t photons); DiffractionSpot(const SpotToSave &save); diff --git a/common/SpotToSave.h b/common/SpotToSave.h index 491f7a96..d866acbc 100644 --- a/common/SpotToSave.h +++ b/common/SpotToSave.h @@ -16,7 +16,6 @@ struct SpotToSave { int64_t h = 0, k = 0, l= 0; float d_A = 0.0; float dist_ewald_sphere = 0.0; - float weight = 1.0f; // soft quality weight in (0,1]; used to rank the kept spots (see --soft-weight) bool ice_ring = false; bool indexed = false; diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index e891604d..a551f567 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -195,83 +195,5 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB output_buffer[OutputSize() - 1] = out.to_ulong(); // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- - auto spots = ExtractSpots(image, settings, res_mask); - if (settings.local_snr > 0.0f) - FilterByLocalSNR(image, spots, settings.local_snr); - if (settings.soft_weight) - ApplyWeights(spots); - return spots; -} - -// Reject spots that do not stand out against their LOCAL background. The loose per-ring threshold -// keeps ~100% of real Bragg peaks but, on structured-background (XFEL) frames, also floods with -// spurious pixels: the azimuthal ring mean underestimates the diffuse level in some sectors, so a -// locally-high background pixel clears it. A real peak stands many sigmas above the background in -// its IMMEDIATE neighbourhood, a flood pixel does not. For each spot the background mean and scatter -// are measured from a square annulus around its centroid (robust median / MAD, so a neighbouring -// peak in the annulus cannot bias it), and the spot is kept only if its integrated signal exceeds -// k local sigmas. k is in sigma units -- self-calibrating, no photon threshold. -void AdaptiveSpotFinderCPU::FilterByLocalSNR(const ImagePreprocessorBuffer &image, - std::vector &spots, float k) const { - constexpr int RIN = 3; // half-width of the excluded core (7x7) - constexpr int ROUT = 6; // half-width of the background annulus (13x13) - std::vector bg; - bg.reserve((2 * ROUT + 1) * (2 * ROUT + 1)); - std::vector kept; - kept.reserve(spots.size()); - for (const auto &s : spots) { - const Coord c = s.RawCoord(); - const int col = static_cast(std::lround(c.x)); - const int row = static_cast(std::lround(c.y)); - bg.clear(); - for (int dr = -ROUT; dr <= ROUT; ++dr) { - const int rr = row + dr; - if (rr < 0 || rr >= height) continue; - for (int dc = -ROUT; dc <= ROUT; ++dc) { - if (std::max(std::abs(dr), std::abs(dc)) <= RIN) continue; // skip the peak core - const int cc = col + dc; - if (cc < 0 || cc >= width) continue; - const int32_t v = image[static_cast(rr) * width + cc]; - if (v == INT32_MIN || v == INT32_MAX) continue; // masked / saturated - bg.push_back(static_cast(v)); - } - } - if (bg.size() < 8) { kept.push_back(s); continue; } // too few bg pixels to judge - const size_t mid = bg.size() / 2; - std::nth_element(bg.begin(), bg.begin() + mid, bg.end()); - const float bg_med = bg[mid]; - for (auto &v : bg) v = std::fabs(v - bg_med); - std::nth_element(bg.begin(), bg.begin() + mid, bg.end()); - const float sigma = std::max(1.4826f * bg[mid], 1.0f); - const double npix = static_cast(std::max(s.PixelCount(), 1)); - const double signal = static_cast(s.Count()) - bg_med * npix; - const double snr = signal / (sigma * std::sqrt(npix)); - if (snr >= static_cast(k)) - kept.push_back(s); - } - spots.swap(kept); -} - -void AdaptiveSpotFinderCPU::ApplyWeights(std::vector &spots) const { - const auto &pixel_to_bin = mapping.GetPixelToBin(); - const size_t nbins = ring_mean.size(); - const float READ = 1.0f; - for (auto &s : spots) { - const Coord c = s.RawCoord(); // flux-weighted centroid (col, row) - const int col = std::min(std::max(static_cast(std::lround(c.x)), 0), width - 1); - const int row = std::min(std::max(static_cast(std::lround(c.y)), 0), height - 1); - const uint16_t b = pixel_to_bin[static_cast(row) * width + col]; - const float mu = (b < nbins) ? ring_mean[b] : 0.0f; - const double N = std::max(s.PixelCount(), 1); - const double tot = std::max(s.Count(), 0); - const double signal = tot - N * mu; - const double noise = std::sqrt(std::max(1.0, tot + N * static_cast(READ) * READ)); - const double snr = signal / noise; - // Dimensionless gates (sigma, pixels): high SNR -> keep; a reasonable pixel count -> keep, while - // 1-pixel noise (rising edge) and oversized ice/salt/streak blobs (falling edge) -> ~0. - const float w_snr = 1.0f / (1.0f + std::exp(-static_cast(snr - 4.0) / 1.5f)); - const float w_size = (1.0f / (1.0f + std::exp(-(static_cast(N) - 1.5f) / 0.7f))) - * (1.0f / (1.0f + std::exp(-(40.0f - static_cast(N)) / 8.0f))); - s.SetWeight(std::min(std::max(w_snr * w_size, 0.0f), 1.0f)); - } + return ExtractSpots(image, settings, res_mask); } diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index 0faf7590..d75e937b 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -36,15 +36,6 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { std::vector ring_thr; void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k); - // Assign each spot a soft quality weight (SNR against the per-ring background x a soft size band), - // consumed downstream to keep the best spots rather than the brightest. Needs the ring background, - // so it must run after AccumulateRings. - void ApplyWeights(std::vector &spots) const; - // Drop spots that do not stand k sigmas above their own LOCAL (annulus) background. The - // discriminator the per-ring threshold lacks on structured-background frames; see the header - // comment on SpotFindingSettings::local_snr. - void FilterByLocalSNR(const ImagePreprocessorBuffer &image, - std::vector &spots, float k) const; public: explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index 4d1dd099..b69b7072 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -31,21 +31,4 @@ struct SpotFindingSettings { // per frame (the threshold's operating point), ~100 for a multi-megapixel detector. bool adaptive_threshold = false; float false_pixels_per_frame = 100.0f; - - // Soft per-spot weighting (implies adaptive detection): assign every detected spot a continuous - // quality weight from its SNR (against the per-ring background) and a soft size band (too few or - // too many pixels -> low), then keep the highest-WEIGHT spots rather than the brightest. Lets a - // deliberately loose detector self-clean -- bright ice/salt/jet blobs and single-pixel noise no - // longer evict faint clean Bragg spots. See AdaptiveSpotFinderCPU::ApplyWeights. - bool soft_weight = false; - - // Local-background SNR gate (implies adaptive detection): after connected-component spots are - // built from the loose per-ring threshold, reject any spot whose integrated signal does not - // clear this many sigmas above its OWN LOCAL background (measured from a robust annulus around - // it), not just the azimuthal ring mean. On structured-background (XFEL) frames the ring mean - // underestimates the local diffuse level in some sectors, so the ring threshold floods with - // spurious pixels; a real Bragg peak still stands many local sigmas proud, so this recovers the - // clean spot list a global threshold cannot. In sigma units -> self-calibrating, no photon - // threshold. 0 disables. See AdaptiveSpotFinderCPU::FilterByLocalSNR. - float local_snr = 0.0f; }; diff --git a/image_analysis/spot_finding/SpotUtils.cpp b/image_analysis/spot_finding/SpotUtils.cpp index da082741..b682649b 100644 --- a/image_analysis/spot_finding/SpotUtils.cpp +++ b/image_analysis/spot_finding/SpotUtils.cpp @@ -37,19 +37,14 @@ void MarkIceRings(std::vector &spots, float tolerance_q_recipA) { } } -void FilterSpotsByCount(std::vector &input, int64_t count, bool by_weight) { +void FilterSpotsByCount(std::vector &input, int64_t count) { size_t output_size = std::min(input.size(), count); std::ranges::partial_sort(input, input.begin() + output_size, std::ranges::less{}, // comparator on the projected key - [by_weight](const SpotToSave &s) { - // projection: key to compare by. non-ice first (false < true), then - // by soft quality weight (higher first) when requested -- so a loose - // detector's bright junk cannot evict faint clean Bragg -- else by - // raw intensity. Intensity is the tie-breaker under the weight. - if (by_weight) - return std::tuple{s.ice_ring, -s.weight, -s.intensity}; - return std::tuple{s.ice_ring, 0.0f, -s.intensity}; + [](const SpotToSave &s) { + // projection: non-ice first (false < true), then strongest intensity first. + return std::tuple{s.ice_ring, -s.intensity}; }); input.resize(output_size); } @@ -156,7 +151,7 @@ void SpotAnalyze(const DiffractionExperiment &experiment, output.resolution_estimate = GetResolution(spots_out); - FilterSpotsByCount(spots_out, experiment.GetMaxSpotCount(), spot_finding_settings.soft_weight); + FilterSpotsByCount(spots_out, experiment.GetMaxSpotCount()); output.spots = spots_out; } diff --git a/image_analysis/spot_finding/SpotUtils.h b/image_analysis/spot_finding/SpotUtils.h index f85441d5..9ba458be 100644 --- a/image_analysis/spot_finding/SpotUtils.h +++ b/image_analysis/spot_finding/SpotUtils.h @@ -18,7 +18,7 @@ void CountSpots(DataMessage &msg, void MarkIceRings(std::vector &spots, float tolerance_q_recipA); -void FilterSpotsByCount(std::vector &input, int64_t count, bool by_weight = false); +void FilterSpotsByCount(std::vector &input, int64_t count); void FilterSpuriousHighResolutionSpots(std::vector &spots, float threshold); // Ignore high res. spots if there is a gap in (1/d) between two spots of dist_threshold (default: 0.25 A^-1) diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 7c258ddb..0b6b6bfa 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -87,8 +87,6 @@ std::string RugnuxCommandLine(const ProcessConfig &config, add("--spot-threshold", std::to_string(sf.photon_count_threshold)); if (sf.adaptive_threshold) add("--spot-false-pixels", num(sf.false_pixels_per_frame)); - if (sf.local_snr > 0.0f) - add("--local-snr", num(sf.local_snr)); add("--spot-high-resolution", num(sf.high_resolution_limit)); add("--max-spots", std::to_string(experiment.GetMaxSpotCount())); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index a490bf93..94f66566 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -67,8 +67,6 @@ void print_usage() { std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; - std::cout << " --soft-weight With --adaptive-spots: weight each spot by SNR + a soft size band and keep the highest-quality (not brightest) spots, so bright ice/salt/noise cannot crowd out faint Bragg" << std::endl; - std::cout << " --local-snr With --adaptive-spots: keep only spots standing this many sigma above their own LOCAL background (annulus), removing structured-background (XFEL) flood the ring threshold lets through (e.g. 5; implies --adaptive-spots)" << std::endl; std::cout << " --min-indexed-fraction Minimum fraction of in-resolution spots a lattice must index to be accepted (default 0.20); lower to admit weaker/sparser crystals (pair with --min-image-cc to gate the extra spurious ones)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; @@ -150,8 +148,6 @@ enum { OPT_MIN_PIX_PER_SPOT, OPT_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, - OPT_SOFT_WEIGHT, - OPT_LOCAL_SNR, OPT_MIN_INDEXED_FRACTION, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, @@ -264,8 +260,6 @@ static option long_options[] = { {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, - {"soft-weight", no_argument, nullptr, OPT_SOFT_WEIGHT}, - {"local-snr", required_argument, nullptr, OPT_LOCAL_SNR}, {"min-indexed-fraction", required_argument, nullptr, OPT_MIN_INDEXED_FRACTION}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, @@ -532,8 +526,6 @@ int main(int argc, char **argv) { int64_t min_pix_per_spot = 2; bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; - bool soft_weight_flag = false; - float local_snr = 0.0f; std::optional min_indexed_fraction; bool refine_bfactor = false; std::string ref_mtz; @@ -773,16 +765,6 @@ int main(int argc, char **argv) { adaptive_spots = true; logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame); break; - case OPT_SOFT_WEIGHT: - adaptive_spots = true; - soft_weight_flag = true; - logger.Info("Soft per-spot weighting enabled (keep highest-quality spots, not brightest)"); - break; - case OPT_LOCAL_SNR: - local_snr = parse_number_arg(optarg, "--local-snr", logger, 0.0f); - adaptive_spots = true; - logger.Info("Local-background SNR gate enabled: keep spots >= {:.1f} sigma above their local background", local_snr); - break; case OPT_MIN_INDEXED_FRACTION: min_indexed_fraction = parse_number_arg(optarg, "--min-indexed-fraction", logger, 0.0f); logger.Info("Minimum indexed-spot fraction for acceptance set to {:.2f}", min_indexed_fraction.value()); @@ -1527,8 +1509,6 @@ int main(int argc, char **argv) { spot_settings.min_pix_per_spot = min_pix_per_spot; spot_settings.adaptive_threshold = adaptive_spots; spot_settings.false_pixels_per_frame = false_pixels_per_frame; - spot_settings.soft_weight = soft_weight_flag; - spot_settings.local_snr = local_snr; if (d_min_spot_finding > 0.0f) spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) -- 2.54.0 From 014e43a4c98bba83b85c69903d9e6d22bf5c055e Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 25 Jul 2026 17:40:48 +0200 Subject: [PATCH 014/295] Remove non-helping stills merge/scaling knobs Trims three opt-in stills parameters that did not improve data quality on the external-reference (PDB R-free) battery and only added code: - --partiality-uncertainty: the (1-p)/p merge-sigma term was null on all four serial-stills datasets of the battery vs their reference structures (and neutral-to-harmful at higher coefficients); removed the flag, setting and CorrectedSigma term. - --stills-modulation: the detector-plane flat-field surface was net-negative on flooded data; removed the flag, setting and MergeOnTheFly::RefineModulation (the rotation modulation in RotationScaleMerge is unaffected). - --min-indexed-fraction: every value other than the 0.20 default collapsed CC1/2; removed the override flag/setter, keeping the fixed 0.20 acceptance floor. Default behaviour is unchanged (all three were off / at their default). Co-Authored-By: Claude Opus 4.8 --- common/IndexingSettings.cpp | 5 - common/IndexingSettings.h | 1 - common/ScalingSettings.cpp | 18 --- common/ScalingSettings.h | 9 -- image_analysis/scale_merge/Merge.cpp | 152 +------------------- image_analysis/scale_merge/Merge.h | 12 +- rugnux/Rugnux.cpp | 11 -- rugnux/RugnuxCommandLine.cpp | 4 - rugnux/rugnux_cli.cpp | 37 ----- viewer/widgets/JFJochViewerSettingsDock.cpp | 29 ---- 10 files changed, 5 insertions(+), 273 deletions(-) diff --git a/common/IndexingSettings.cpp b/common/IndexingSettings.cpp index 1400f85c..122bd0e8 100644 --- a/common/IndexingSettings.cpp +++ b/common/IndexingSettings.cpp @@ -28,11 +28,6 @@ int64_t IndexingSettings::GetViableCellMinSpots() const { return viable_cell_min_spots; } -IndexingSettings &IndexingSettings::MinIndexedSpotFraction(float input) { - min_indexed_spot_fraction = input; - return *this; -} - float IndexingSettings::GetMinIndexedSpotFraction() const { return min_indexed_spot_fraction; } diff --git a/common/IndexingSettings.h b/common/IndexingSettings.h index 2872fc6d..7c8ee11d 100644 --- a/common/IndexingSettings.h +++ b/common/IndexingSettings.h @@ -43,7 +43,6 @@ public: IndexingSettings(); IndexingSettings& ViableCellMinSpots(int64_t input); - IndexingSettings& MinIndexedSpotFraction(float input); IndexingSettings& Algorithm(IndexingAlgorithmEnum input); IndexingSettings& FFT_MaxUnitCell_A(float input); IndexingSettings& FFT_MinUnitCell_A(float input); diff --git a/common/ScalingSettings.cpp b/common/ScalingSettings.cpp index 08ff80c6..fe7a8952 100644 --- a/common/ScalingSettings.cpp +++ b/common/ScalingSettings.cpp @@ -151,15 +151,6 @@ bool ScalingSettings::GetCorrectionSurfaces() const { return correction_surfaces; } -ScalingSettings &ScalingSettings::StillsModulation(bool input) { - stills_modulation = input; - return *this; -} - -bool ScalingSettings::GetStillsModulation() const { - return stills_modulation; -} - ScalingSettings &ScalingSettings::StillsPartialityRefine(bool input) { stills_partiality_refine = input; return *this; @@ -229,15 +220,6 @@ double ScalingSettings::GetCaptureUncertaintyCoeff() const { return capture_uncertainty_coeff; } -ScalingSettings &ScalingSettings::PartialityUncertaintyCoeff(double input) { - partiality_uncertainty_coeff = input; - return *this; -} - -double ScalingSettings::GetPartialityUncertaintyCoeff() const { - return partiality_uncertainty_coeff; -} - ScalingSettings &ScalingSettings::MinCapturedFraction(double input) { if (input < 0.0) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, diff --git a/common/ScalingSettings.h b/common/ScalingSettings.h index f215709f..b372e00b 100644 --- a/common/ScalingSettings.h +++ b/common/ScalingSettings.h @@ -27,7 +27,6 @@ class ScalingSettings { // a fraction f<1 of its rocking curve is extrapolated, and the unobserved (1-f) carries a // systematic error ~coeff*(1-f)*I that plain counting sigma misses. 0 = off (baseline). double capture_uncertainty_coeff = 0.0; - double partiality_uncertainty_coeff = 0.0; // Full-level captured-fraction floor for the rot3d combine: drop a reconstructed full whose rocking // curve was only fractionally captured (sum of its partials' partiality < this). Distinct from // min_partiality, which gates individual partials; this gates the assembled full. 0 = off (baseline). @@ -46,10 +45,6 @@ class ScalingSettings { bool correction_surfaces = true; // Absorption-surface refinement iteration count (used when correction_surfaces is on). int absorption_iter = 3; - // Detector-plane modulation (flat-field) correction for the STILLS merge path (ScaleOnTheFly + - // MergeOnTheFly), which otherwise has no correction surfaces. Off by default (experimental; the - // rotation path fits its own modulation via RotationScaleMerge). Enabled by rugnux --stills-modulation. - bool stills_modulation = false; // Physical partiality post-refinement for the STILLS merge (StillsPartialityRefine): refine a per-crystal // orientation tilt against the running merge, recompute each reflection's partiality from the refined @@ -100,14 +95,12 @@ public: ScalingSettings& MinPartiality(double min_partiality); ScalingSettings& ForcedMosaicity(std::optional input); ScalingSettings& CaptureUncertaintyCoeff(double input); - ScalingSettings& PartialityUncertaintyCoeff(double input); ScalingSettings& MinCapturedFraction(double input); ScalingSettings& MinCCForImage(double min_cc_for_image); ScalingSettings& OutlierRejectNsigma(double input); ScalingSettings& ScaleFulls(bool input); ScalingSettings& AbsorptionIter(int input); ScalingSettings& CorrectionSurfaces(bool input); - ScalingSettings& StillsModulation(bool input); ScalingSettings& StillsPartialityRefine(bool input); ScalingSettings& ExpectedVarianceMerge(bool input); ScalingSettings& SmoothGDegrees(double input); @@ -141,14 +134,12 @@ public: [[nodiscard]] double GetMinPartiality() const; [[nodiscard]] std::optional GetForcedMosaicity() const; [[nodiscard]] double GetCaptureUncertaintyCoeff() const; - [[nodiscard]] double GetPartialityUncertaintyCoeff() const; [[nodiscard]] double GetMinCapturedFraction() const; [[nodiscard]] double GetMinCCForImage() const; [[nodiscard]] double GetOutlierRejectNsigma() const; [[nodiscard]] bool GetScaleFulls() const; [[nodiscard]] int GetAbsorptionIter() const; [[nodiscard]] bool GetCorrectionSurfaces() const; - [[nodiscard]] bool GetStillsModulation() const; [[nodiscard]] bool GetStillsPartialityRefine() const; [[nodiscard]] bool GetExpectedVarianceMerge() const; [[nodiscard]] double GetSmoothGDegrees() const; diff --git a/image_analysis/scale_merge/Merge.cpp b/image_analysis/scale_merge/Merge.cpp index 82f91431..fea7100b 100644 --- a/image_analysis/scale_merge/Merge.cpp +++ b/image_analysis/scale_merge/Merge.cpp @@ -93,7 +93,7 @@ void MergeOnTheFly::AddImage(const IntegrationOutcome &outcome, int64_t image_id continue; auto hkl = generator(r); auto hkl_key = hkl.pack(); - sigma_corr = CorrectedSigma(I_corr, sigma_corr, r.image_scale_corr, hkl_key, r.partiality); + sigma_corr = CorrectedSigma(I_corr, sigma_corr, r.image_scale_corr, hkl_key); // Robust outlier rejection: drop this observation if it sits more than // reject_nsigma error-model sigmas from the reflection's median. Needs the active @@ -131,139 +131,8 @@ void MergeOnTheFly::AddImage(const IntegrationOutcome &outcome, int64_t image_id } } -double MergeOnTheFly::RefineModulation(std::vector &outcomes) { - // A minimum held-out generalizing gain (fraction of the held-out scatter) before the surface is - // applied - a margin, so a noise-level "improvement" never engages the correction. Matches the - // rotation ApplyCellSurface gate. - constexpr double CV_MIN_RELATIVE_GAIN = 0.02; - constexpr int NB = 16; - const int ncell = NB * NB; - - // One scaled observation reduced to what the surface fit needs. `parity` (image index & 1) drives the - // even/odd cross-validation split - the stills analogue of the rotation frame parity. - struct MObs { double I, sigma, corr; float px, py; int32_t group; int parity; int cell; }; - std::vector obs; - - // Accept exactly what AddImage merges (systematic absence, scale/resolution/ice/partiality filters), - // and additionally require a finite detector position. Returns the dense ASU-group id or -1. - std::unordered_map group_of; - auto accept = [&](const Reflection &r, MObs &out) -> bool { - if (generator.IsSystematicallyAbsent(r)) return false; - if (r.image_scale_corr <= 0.0f || !std::isfinite(r.image_scale_corr)) return false; - if (!AcceptReflection(r, high_resolution_limit)) return false; - if (exclude_ice_rings && r.on_ice_ring) return false; - if (IsMaskedRing(r)) return false; - if (r.partiality < min_partiality) return false; - if (!std::isfinite(r.predicted_x) || !std::isfinite(r.predicted_y)) return false; - const float I_corr = r.I * r.image_scale_corr, sigma_corr = r.sigma * r.image_scale_corr; - if (!std::isfinite(I_corr) || !std::isfinite(sigma_corr) || sigma_corr <= 0.0f) return false; - const uint64_t key = generator(r).pack(); - auto [it, inserted] = group_of.try_emplace(key, static_cast(group_of.size())); - out.I = r.I; out.sigma = r.sigma; out.corr = r.image_scale_corr; - out.px = r.predicted_x; out.py = r.predicted_y; out.group = it->second; - return true; - }; - - float pxmin = std::numeric_limits::infinity(), pxmax = -pxmin, pymin = pxmin, pymax = -pxmin; - for (size_t i = 0; i < outcomes.size(); ++i) { - const int parity = static_cast(i & 1); - for (const auto &r : outcomes[i].reflections) { - MObs m{}; - if (!accept(r, m)) continue; - m.parity = parity; - pxmin = std::min(pxmin, m.px); pxmax = std::max(pxmax, m.px); - pymin = std::min(pymin, m.py); pymax = std::max(pymax, m.py); - obs.push_back(m); - } - } - const int n_groups = static_cast(group_of.size()); - if (obs.size() < static_cast(8 * ncell) || !(pxmax > pxmin) || !(pymax > pymin)) - return 0.0; // too sparse to over-determine a 16x16 surface, or degenerate detector footprint - - const float sx = NB / (pxmax - pxmin), sy = NB / (pymax - pymin); - for (auto &m : obs) { - const int ix = std::clamp(static_cast((m.px - pxmin) * sx), 0, NB - 1); - const int iy = std::clamp(static_cast((m.py - pymin) * sy), 0, NB - 1); - m.cell = ix * NB + iy; - } - - // Fit the per-cell factor over {parity subset} (parity < 0 = all obs), n_iter alternating rounds against - // that subset's own inverse-variance reference: Tikhonov pull to 1, gauge-fixed to a den-weighted - // geometric mean of 1 so it never drifts the overall scale. Mirrors RotationScaleMerge::ApplyCellSurface. - constexpr int N_ITER = 3; - auto fit_surface = [&](int parity) -> std::vector { - std::vector A(ncell, 1.0); - for (int it = 0; it < N_ITER; ++it) { - std::vector sw(n_groups, 0.0), swI(n_groups, 0.0); - for (const auto &o : obs) { - if (parity >= 0 && o.parity != parity) continue; - const double a = A[o.cell], sc = o.sigma * o.corr * a, w = 1.0 / (sc * sc); - sw[o.group] += w; swI[o.group] += w * o.I * o.corr * a; - } - std::vector num(ncell, 0.0), den(ncell, 0.0); - for (const auto &o : obs) { - if ((parity >= 0 && o.parity != parity) || sw[o.group] <= 0.0) continue; - const double Iref = swI[o.group] / sw[o.group], a = A[o.cell]; - const double Is = o.I * o.corr * a, sc = o.sigma * o.corr * a; - if (!std::isfinite(Iref) || Iref <= 0.0 || !(Is > 0.0) || !(sc > 0.0)) continue; - const double w = 1.0 / (sc * sc); - num[o.cell] += w * Is * Iref; den[o.cell] += w * Is * Is; - } - std::vector dsorted = den; - std::nth_element(dsorted.begin(), dsorted.begin() + dsorted.size() / 2, dsorted.end()); - const double lambda = 0.1 * std::max(1e-30, dsorted[dsorted.size() / 2]); - double logsum = 0.0, wsum = 0.0; - std::vector upd(ncell, 1.0); - for (int c = 0; c < ncell; ++c) upd[c] = (num[c] + lambda) / (den[c] + lambda); - for (int c = 0; c < ncell; ++c) if (den[c] > 0.0) { logsum += den[c] * std::log(upd[c]); wsum += den[c]; } - const double gm = wsum > 0.0 ? std::exp(logsum / wsum) : 1.0; - for (int c = 0; c < ncell; ++c) A[c] = std::clamp(A[c] * upd[c] / gm, 0.25, 4.0); - } - return A; - }; - // Sigma-independent (R-meas-like) agreement of the held-out equivalents: sum|Is - Iref| / sum|Iref|. - // A fractional metric cannot be gamed by a surface that merely reshapes sigma via corr. - auto score = [&](int parity, const std::vector &A) -> double { - std::vector sw(n_groups, 0.0), swI(n_groups, 0.0); - for (const auto &o : obs) { - if (o.parity != parity) continue; - const double a = A[o.cell], Is = o.I * o.corr * a, sc = o.sigma * o.corr * a, w = 1.0 / (sc * sc); - sw[o.group] += w; swI[o.group] += w * Is; - } - double num = 0.0, den = 0.0; - for (const auto &o : obs) { - if (o.parity != parity || sw[o.group] <= 0.0) continue; - const double a = A[o.cell], Is = o.I * o.corr * a, Iref = swI[o.group] / sw[o.group]; - if (!std::isfinite(Iref) || Iref <= 0.0) continue; - num += std::abs(Is - Iref); den += Iref; - } - return den > 0.0 ? num / den : 0.0; - }; - - // Cross-validate: fit on even images, score the held-out odd equivalents (and vice versa). Apply the - // full-data surface only if the held-out agreement improves by a clear margin. - const std::vector ident(ncell, 1.0); - const std::vector A_even = fit_surface(0), A_odd = fit_surface(1); - const double base = score(1, ident) + score(0, ident); - const double gain = base - (score(1, A_even) + score(0, A_odd)); - if (!(gain > CV_MIN_RELATIVE_GAIN * base)) - return 0.0; // not cross-validated: the correction stays a no-op (caller logs) - const std::vector A = fit_surface(-1); - - // Fold the surface into each accepted reflection's image_scale_corr (recompute its cell deterministically). - for (auto &outcome : outcomes) - for (auto &r : outcome.reflections) { - MObs m{}; - if (!accept(r, m)) continue; - const int ix = std::clamp(static_cast((m.px - pxmin) * sx), 0, NB - 1); - const int iy = std::clamp(static_cast((m.py - pymin) * sy), 0, NB - 1); - r.image_scale_corr = static_cast(r.image_scale_corr * A[ix * NB + iy]); - } - return gain / std::max(base, 1e-30); // held-out gain fraction (caller logs) -} - float MergeOnTheFly::CorrectedSigma(float I_corr, float sigma_corr, float image_scale_corr, - uint64_t hkl_key, float partiality) const { + uint64_t hkl_key) const { if (!error_model_active) return sigma_corr; @@ -288,21 +157,8 @@ float MergeOnTheFly::CorrectedSigma(float I_corr, float sigma_corr, float image_ a_var = base; } - double v = error_model_a * a_var - + (error_model_b * I_for_b) * (error_model_b * I_for_b); - - // Partiality-model uncertainty: a reflection recorded at fraction p carries a systematic intensity - // error ~ (dp/p) that is proportional to and grows as p falls - plain counting sigma misses it, - // so strong low-p partials would otherwise be over-trusted. This is the stills-partiality analog of - // the rotation --capture-uncertainty term ((1-captured_fraction)*I in RotationScaleMerge). Inert when - // partiality == 1 (no stills partiality model). Gated on a real systematic (error_model_b > 1, i.e. - // ISa < 1): on weak counting-limited data (small b) it would only over-concentrate the merge and hurt. - const double c = scaling_settings.GetPartialityUncertaintyCoeff(); - if (c > 0.0 && error_model_b > 1.0) { - const double one_minus_p = std::max(0.0, std::min(1.0, 1.0 - static_cast(partiality))); - const double t = c * I_for_b * one_minus_p; - v += t * t; - } + const double v = error_model_a * a_var + + (error_model_b * I_for_b) * (error_model_b * I_for_b); return (v > 0.0) ? static_cast(std::sqrt(v)) : sigma_corr; } diff --git a/image_analysis/scale_merge/Merge.h b/image_analysis/scale_merge/Merge.h index 5b9dc093..cd19b7bd 100644 --- a/image_analysis/scale_merge/Merge.h +++ b/image_analysis/scale_merge/Merge.h @@ -130,7 +130,7 @@ class MergeOnTheFly { // using the per-observation I_i instead would over-weight down-fluctuated points. std::unordered_map error_model_mean_I; [[nodiscard]] float CorrectedSigma(float I_corr, float sigma_corr, float image_scale_corr, - uint64_t hkl_key, float partiality) const; + uint64_t hkl_key) const; // Optional per-observation outlier rejection: drop observations whose corrected // intensity lies more than reject_nsigma error-model sigmas from the reflection's @@ -168,16 +168,6 @@ public: // order (or threading) of AddImage calls - not a draw from a shared RNG in call order. void AddImage(const IntegrationOutcome& outcome, int64_t image_id, bool cc_mask = false); - // Detector-plane modulation (flat-field) correction for the STILLS path: fit a smooth multiplicative - // factor over where a reflection lands on the detector (predicted x,y) against the merged reference and - // fold it into each reflection's image_scale_corr, so the following AddImage merge (and the error model) - // see the corrected scale. A 16x16 grid, cross-validated (fit even images, score the held-out odd - // equivalents by a sigma-independent R-meas-like metric) so it is a no-op when the systematic is absent - // or the data too sparse. The stills analogue of RotationScaleMerge::RefineModulation - the stills - // ScaleOnTheFly/MergeOnTheFly path otherwise has no correction surfaces. Mutates `outcomes`; call after - // scaling and before RefineErrorModel/AddImage. Returns the applied held-out gain fraction (0 = no-op). - double RefineModulation(std::vector &outcomes); - // d_min_override, when set, is the effective high-resolution limit for the shell table (used for // the automatic resolution cutoff computed by the caller); otherwise the manual // ScalingSettings high-resolution limit stands. The number of shells is ScalingSettings::ReportShellCount. diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 3d4c4bce..9dacc5d9 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -991,17 +991,6 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b merge_engine.MaskIceRings(masked_ice_rings, config_.spot_finding.ice_ring_width_Q_recipA); if (result.consensus_cell.has_value()) merge_engine.ReferenceCell(*result.consensus_cell); - // Optional detector-plane modulation (flat-field) correction for stills, folded into each - // reflection's scale before the error model and merge (never for the P1 search pass). The - // rotation path fits its own modulation inside RotationScaleMerge. - if (experiment_.GetScalingSettings().GetStillsModulation() && !for_search) { - const double mod_gain = merge_engine.RefineModulation(indexer->GetIntegrationOutcome()); - if (mod_gain > 0.0) - logger.Info("Stills modulation: detector-frame 16x16 surface applied " - "(cross-validated, held-out gain {:.1f}%)", 100.0 * mod_gain); - else - logger.Info("Stills modulation: no cross-validated gain (skipped)"); - } merge_engine.RefineErrorModel(merge_input); if (merge_engine.ErrorModelActive()) logger.Info("Error model: a={:.3f} b={:.3f} ISa={:.1f} chi2={:.2f}", merge_engine.ErrorModelA(), diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 0b6b6bfa..e2fdab15 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -149,10 +149,6 @@ std::string RugnuxCommandLine(const ProcessConfig &config, args.emplace_back("-A"); if (sc.GetRefineB()) args.emplace_back("-B"); - if (sc.GetPartialityUncertaintyCoeff() > 0.0) - add("--partiality-uncertainty", num(sc.GetPartialityUncertaintyCoeff())); - if (sc.GetStillsModulation()) - args.emplace_back("--stills-modulation"); if (!sc.GetStillsPartialityRefine()) args.emplace_back("--simple-stills"); if (!sc.GetExpectedVarianceMerge()) diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 94f66566..17eff6e5 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -67,7 +67,6 @@ void print_usage() { std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; - std::cout << " --min-indexed-fraction Minimum fraction of in-resolution spots a lattice must index to be accepted (default 0.20); lower to admit weaker/sparser crystals (pair with --min-image-cc to gate the extra spurious ones)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; @@ -97,7 +96,6 @@ void print_usage() { std::cout << " --smooth-g[=deg] rot3d: smooth per-frame scale G over a deg-degree rotation range (XDS DELPHI-like) before the combine (default: 5 for rot3d; 0 = off)" << std::endl; std::cout << " --relative-b[=deg] rot3d: fit a per-batch relative-B (beyond the single decay slope) over deg-degree batches; cross-validated (default: 10 deg when bare; off otherwise)" << std::endl; std::cout << " --no-scaling-corrections rot3d: disable the (default-on) decay + absorption correction surfaces fitted on the fulls after scale-fulls" << std::endl; - std::cout << " --stills-modulation stills: fit a detector-plane modulation (flat-field) surface over the merged reflections (cross-validated; experimental, default off)" << std::endl; std::cout << " --no-expected-variance-merge stills: disable the default expected-variance merge weighting (which rebuilds each weak observation's signal variance at the reflection mean to de-bias the inverse-variance merge); restores observed-sigma weighting" << std::endl; std::cout << " -A, --anomalous Anomalous mode (don't merge Friedel pairs)" << std::endl; std::cout << " -B, --refine-bfactor Refine per image B-factor (stills only)" << std::endl; @@ -107,7 +105,6 @@ void print_usage() { std::cout << " --resolution-shells Number of resolution shells in the reported statistics table (default: 10)" << std::endl; std::cout << " --min-partiality Minimum partiality to accept reflection (default: 0.02)" << std::endl; std::cout << " --capture-uncertainty rot3d: systematic sigma ~num*(1-captured_fraction)*I on under-captured fulls (default: 1.0 for rot3d, 0 otherwise)" << std::endl; - std::cout << " --partiality-uncertainty stills: extra merge sigma ~num*(1-partiality)* on partials (auto-gated to error-model b>1 / ISa<1; default 0, ~2.5 recommended)" << std::endl; std::cout << " --min-captured-fraction rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl; std::cout << " --mosaicity Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl; std::cout << " --reject-outliers Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl; @@ -148,7 +145,6 @@ enum { OPT_MIN_PIX_PER_SPOT, OPT_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, - OPT_MIN_INDEXED_FRACTION, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, OPT_MAX_SPOTS, @@ -175,13 +171,11 @@ enum { OPT_SIMPLE_STILLS, OPT_SCALE_FULLS, OPT_CAPTURE_UNCERTAINTY, - OPT_PARTIALITY_UNCERTAINTY, OPT_MIN_CAPTURED_FRACTION, OPT_MOSAICITY, OPT_SMOOTH_G, OPT_RELATIVE_B, OPT_NO_SCALING_CORRECTIONS, - OPT_STILLS_MODULATION, OPT_NO_EXPECTED_VARIANCE_MERGE, OPT_DETECT_ICE_RINGS, OPT_NO_SCALE_FULLS, @@ -229,7 +223,6 @@ static option long_options[] = { {"smooth-g", optional_argument, nullptr, OPT_SMOOTH_G}, {"relative-b", optional_argument, nullptr, OPT_RELATIVE_B}, {"no-scaling-corrections", no_argument, nullptr, OPT_NO_SCALING_CORRECTIONS}, - {"stills-modulation", no_argument, nullptr, OPT_STILLS_MODULATION}, {"no-expected-variance-merge", no_argument, nullptr, OPT_NO_EXPECTED_VARIANCE_MERGE}, {"refine", required_argument, nullptr, 'r'}, @@ -260,13 +253,11 @@ static option long_options[] = { {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, - {"min-indexed-fraction", required_argument, nullptr, OPT_MIN_INDEXED_FRACTION}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, {"max-spots", required_argument, nullptr, OPT_MAX_SPOTS}, {"min-partiality", required_argument, nullptr, OPT_MIN_PARTIALITY}, {"capture-uncertainty", required_argument, nullptr, OPT_CAPTURE_UNCERTAINTY}, - {"partiality-uncertainty", required_argument, nullptr, OPT_PARTIALITY_UNCERTAINTY}, {"min-captured-fraction", required_argument, nullptr, OPT_MIN_CAPTURED_FRACTION}, {"mosaicity", required_argument, nullptr, OPT_MOSAICITY}, {"min-image-cc", required_argument, nullptr, OPT_MIN_IMAGE_CC}, @@ -515,7 +506,6 @@ int main(int argc, char **argv) { std::optional smooth_g_deg_arg; // --smooth-g[=deg]; default 5 deg for rot3d, 0 (off) otherwise std::optional relative_b_deg_arg; // --relative-b[=deg]; per-batch relative-B width, 0 (off) unless given bool no_scaling_corrections = false; // --no-scaling-corrections: disable rot3d decay+absorption surfaces - bool stills_modulation_flag = false; // --stills-modulation: detector-plane flat-field surface for stills bool no_expected_variance_merge = false; // --no-expected-variance-merge: restore observed-sigma stills merge weighting bool anomalous_mode = false; std::optional space_group_number; @@ -526,7 +516,6 @@ int main(int argc, char **argv) { int64_t min_pix_per_spot = 2; bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; - std::optional min_indexed_fraction; bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; @@ -535,7 +524,6 @@ int main(int argc, char **argv) { double min_partiality = 0.02; std::optional min_captured_fraction_arg; // explicit --min-captured-fraction; default depends on rotation std::optional capture_uncertainty_arg; // explicit --capture-uncertainty; default depends on rot3d - std::optional partiality_uncertainty_arg; // --partiality-uncertainty (stills partiality merge sigma) std::optional forced_mosaicity_arg; // diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed double min_image_cc = 0.0; int64_t scaling_iter = 3; @@ -765,10 +753,6 @@ int main(int argc, char **argv) { adaptive_spots = true; logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame); break; - case OPT_MIN_INDEXED_FRACTION: - min_indexed_fraction = parse_number_arg(optarg, "--min-indexed-fraction", logger, 0.0f); - logger.Info("Minimum indexed-spot fraction for acceptance set to {:.2f}", min_indexed_fraction.value()); - break; case OPT_SPOT_LOW_RESOLUTION: d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); @@ -815,9 +799,6 @@ int main(int argc, char **argv) { case OPT_RELATIVE_B: relative_b_deg_arg = optarg ? parse_double_arg(optarg, "--relative-b", logger) : RELATIVE_B_DEFAULT_DEG; break; - case OPT_STILLS_MODULATION: - stills_modulation_flag = true; - break; case OPT_NO_EXPECTED_VARIANCE_MERGE: no_expected_variance_merge = true; break; @@ -830,9 +811,6 @@ int main(int argc, char **argv) { case OPT_CAPTURE_UNCERTAINTY: capture_uncertainty_arg = parse_double_arg(optarg, "--capture-uncertainty", logger); break; - case OPT_PARTIALITY_UNCERTAINTY: - partiality_uncertainty_arg = parse_double_arg(optarg, "--partiality-uncertainty", logger); - break; case OPT_MIN_CAPTURED_FRACTION: min_captured_fraction_arg = parse_double_arg(optarg, "--min-captured-fraction", logger); break; @@ -1099,7 +1077,6 @@ int main(int argc, char **argv) { scaling_settings.MinCapturedFraction(min_captured_fraction_arg.value_or( (experiment.GetGoniometer().has_value() && !force_still) ? 0.7 : 0.0)); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is percent; the setting is a fraction - scaling_settings.StillsModulation(stills_modulation_flag); scaling_settings.StillsPartialityRefine(!simple_stills_flag); scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); scaling_settings.OutlierRejectNsigma( @@ -1167,16 +1144,6 @@ int main(int argc, char **argv) { } MergeOnTheFly merge_engine(experiment); merge_engine.ReferenceCell(experiment.GetUnitCell()); - // Optional detector-plane modulation (flat-field) correction, folded into each reflection's - // scale before the error model + merge (mirrors the full-analysis stills path in Rugnux.cpp). - if (experiment.GetScalingSettings().GetStillsModulation()) { - const double mod_gain = merge_engine.RefineModulation(reflections); - if (mod_gain > 0.0) - logger.Info("Stills modulation: detector-frame 16x16 surface applied " - "(cross-validated, held-out gain {:.1f}%)", 100.0 * mod_gain); - else - logger.Info("Stills modulation: no cross-validated gain (skipped)"); - } // Fit the (a, b) error model from symmetry-mate scatter before merging, exactly as the full // pipeline does (Rugnux.cpp). Without this the offline --scale merge would use the identity // model and produce much worse stills intensities (no (b*I)^2 systematic term, no sigma floor). @@ -1404,8 +1371,6 @@ int main(int argc, char **argv) { if (rotation_indexing_range.has_value()) indexing_settings.RotationIndexingMinAngularRange_deg(rotation_indexing_range.value()); indexing_settings.GeomRefinementAlgorithm(refinement_algorithm); - if (min_indexed_fraction.has_value()) - indexing_settings.MinIndexedSpotFraction(min_indexed_fraction.value()); experiment.ImportIndexingSettings(indexing_settings); // --detect-ice-rings[=on|off] overrides the value carried in from the dataset (HDF5MetadataSource @@ -1423,7 +1388,6 @@ int main(int argc, char **argv) { scaling_settings.RelativeBDegrees(relative_b_deg_arg.value_or(0.0)); // opt-in only; default off if (no_scaling_corrections) scaling_settings.CorrectionSurfaces(false); - scaling_settings.StillsModulation(stills_modulation_flag); scaling_settings.StillsPartialityRefine(!simple_stills_flag); scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge); if (d_min_scale_merge) @@ -1446,7 +1410,6 @@ int main(int argc, char **argv) { // over-extrapolated under-captured fulls and, with the mosaicity fix, lifts rotation ISa/anomalous // substantially. Off for non-rot3d (no combine). An explicit --capture-uncertainty always wins. scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(rotation_indexing ? 1.0 : 0.0)); - scaling_settings.PartialityUncertaintyCoeff(partiality_uncertainty_arg.value_or(0.0)); scaling_settings.ForcedMosaicity(forced_mosaicity_arg); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction scaling_settings.OutlierRejectNsigma( diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index fea318b9..ce86c51a 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -499,11 +499,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { corrections->setToolTip("Rotation only: fit a radiation-damage decay and a goniometer-frame absorption " "surface on the fulls. Cross-validated, so they no-op when their systematic is " "absent. On by default."); - auto *modulation = new QCheckBox("Detector-plane modulation (stills)", this); - modulation->setChecked(scaling_.GetStillsModulation()); - modulation->setToolTip("Stills: fit a detector-plane modulation (flat-field) surface over where each " - "reflection lands, cross-validated so it no-ops when the systematic is absent. " - "For rotation, modulation is part of \"Correction surfaces\" above."); auto *partRefine = new QCheckBox("Partiality post-refinement (stills)", this); partRefine->setChecked(scaling_.GetStillsPartialityRefine()); partRefine->setToolTip("Stills: refine a per-crystal orientation tilt against the running merge and " @@ -515,23 +510,9 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { auto *highRes = new NumberLineEdit(0.3f, 5.0f, scaling_.GetHighResolutionLimit_A().value_or(2.0), 1, "Å", this); highRes->setEnabled(limitRes->isChecked()); - // Stills partiality-uncertainty merge term: adds a systematic sigma ~c*(1-partiality)* on partials, - // so strong low-partiality partials are not over-trusted. Relevant once partials exist (partiality - // post-refinement on, the default); the library auto-gates it to strong/medium data. 0 = off; ~2.5 rec. - const double part_unc = scaling_.GetPartialityUncertaintyCoeff(); - auto *partUncertain = new QCheckBox("Partiality uncertainty", this); - partUncertain->setChecked(part_unc > 0.0); - partUncertain->setToolTip("Stills: add a systematic merge σ ~c·(1−partiality)·⟨I⟩ to partials so strong " - "low-partiality partials are not over-trusted. Relevant with partiality " - "post-refinement (the default); auto-gated to strong/medium data. ~2.5 " - "recommended; unchecked = off."); - auto *partUncertainCoeff = new NumberLineEdit(0.1f, 10.0f, part_unc > 0.0 ? part_unc : 2.5, 1, "", this); - partUncertainCoeff->setEnabled(partUncertain->isChecked()); - form->addRow("", friedel); form->addRow("", refineB); form->addRow("", corrections); - form->addRow("", modulation); form->addRow("", partRefine); // Compact, and aligned with the checkboxes above: the limit checkbox + value sit together in the // field column (not as a row label, which would indent it differently). @@ -539,10 +520,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { resRow->addWidget(limitRes); resRow->addWidget(highRes, 1); form->addRow("", resRow); - auto *puRow = new QHBoxLayout(); - puRow->addWidget(partUncertain); - puRow->addWidget(partUncertainCoeff, 1); - form->addRow("", puRow); section->setContentLayout(form); section->setExpanded(false); // folded on start (only geometry + unit cell start open) @@ -550,24 +527,18 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { scaling_.MergeFriedel(friedel->isChecked()); scaling_.RefineB(refineB->isChecked()); scaling_.CorrectionSurfaces(corrections->isChecked()); - scaling_.StillsModulation(modulation->isChecked()); scaling_.StillsPartialityRefine(partRefine->isChecked()); scaling_.HighResolutionLimit_A(limitRes->isChecked() ? std::optional(highRes->value()) : std::nullopt); - scaling_.PartialityUncertaintyCoeff(partUncertain->isChecked() ? partUncertainCoeff->value() : 0.0); emit scalingChanged(scaling_); }; connect(friedel, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(refineB, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); - connect(modulation, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(partRefine, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(limitRes, &QCheckBox::toggled, this, [emitScaling, highRes](bool on) { highRes->setEnabled(on); emitScaling(); }); connect(highRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); }); - connect(partUncertain, &QCheckBox::toggled, this, [emitScaling, partUncertainCoeff](bool on) { - partUncertainCoeff->setEnabled(on); emitScaling(); }); - connect(partUncertainCoeff, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); }); return section; } -- 2.54.0 From 9fdeed282a7d83eadb5c566a1b9a6dc86185c7f3 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 25 Jul 2026 20:10:45 +0200 Subject: [PATCH 015/295] Add fused GPU adaptive spot finder (azint + spot finding in one pass) AdaptiveSpotFinderGPU does the per-resolution-ring reduction once on the GPU and drives both products from it: the azimuthal-integration profile (corrected space) and the self-calibrating adaptive spot-detection threshold (raw counts). This replaces the separate GPU azint pass and the host-side adaptive spot finder that runs on the GPU path today. On a ~4.5 MP detector it does both jobs in ~1 ms/frame versus ~40 ms for the CPU adaptive finder (~42x), with an identical spot list and azimuthal profile. The per-ring threshold math (Poisson tail + read-floored Gaussian, operating point from the false-pixels-per-frame knob) is factored into AdaptiveThreshold.h so the CPU and GPU finders share one source of truth and cannot drift. Wired opt-in via a MXAnalysisWithoutFPGA constructor flag, default on for the rugnux offline path and the interactive viewer, off for the online receiver (so the broker path is unchanged). When on, Analyze() skips the separate azint pass and lifts the profile from the fused engine. The viewer gains an "Adaptive threshold" checkbox that greys out the signal/noise and photon-count sliders (the adaptive finder uses neither). Dedicated tests exercise both products (spot-finding parity vs the CPU finder, azimuthal profile vs a standalone GPU azint) plus a speed benchmark. Validated end-to-end on lysozyme serial stills: fused == CPU-adaptive index rate and merge stats. Docs: new section 3.2 in docs/CPU_DATA_ANALYSIS.md. Co-Authored-By: Claude Opus 4.8 --- docs/CPU_DATA_ANALYSIS.md | 18 +- image_analysis/MXAnalysisWithoutFPGA.cpp | 42 ++- image_analysis/MXAnalysisWithoutFPGA.h | 19 +- .../spot_finding/AdaptiveSpotFinderCPU.cpp | 85 +---- .../spot_finding/AdaptiveSpotFinderGPU.cu | 319 ++++++++++++++++++ .../spot_finding/AdaptiveSpotFinderGPU.h | 103 ++++++ .../spot_finding/AdaptiveThreshold.h | 91 +++++ image_analysis/spot_finding/CMakeLists.txt | 4 +- rugnux/Rugnux.cpp | 9 +- tests/AdaptiveSpotFinderGPUTest.cpp | 197 +++++++++++ tests/CMakeLists.txt | 1 + viewer/JFJochImageReadingWorker.cpp | 2 +- viewer/widgets/JFJochViewerSettingsDock.cpp | 18 + 13 files changed, 812 insertions(+), 96 deletions(-) create mode 100644 image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu create mode 100644 image_analysis/spot_finding/AdaptiveSpotFinderGPU.h create mode 100644 image_analysis/spot_finding/AdaptiveThreshold.h create mode 100644 tests/AdaptiveSpotFinderGPUTest.cpp diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index b0c721f7..d9aa7df5 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -185,7 +185,21 @@ Special cases: - saturated pixels can be forced to “strong” (useful for detecting overloaded Bragg spots), - invalid pixels are never strong. -### 3.2 Resolution and ice-ring handling +### 3.2 Adaptive (self-calibrating) detection + +The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. An optional **adaptive** mode (`--adaptive-spots`) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. + +Pixels are binned into the same resolution rings as the azimuthal integrator (§2). For each ring a robust background is estimated in three passes: one plain pass over all valid pixels, then two $\sigma$-clipping passes that keep only pixels within $\pm 3\sigma$ of the current ring mean (removing the Bragg peaks from the background estimate). This yields a per-ring background mean $\mu_b$ and scatter $\sigma_b$. + +The ring's detection threshold is the larger of two arms, +$ +t_b = \max\!\big(\;\mu_b + z\,\sqrt{\sigma_b^2 + \sigma_\mathrm{read}^2}\;,\;\; k_\mathrm{Poisson}(\mu_b, p)\;\big), +$ +where $k_\mathrm{Poisson}(\mu_b,p)$ is the smallest count whose Poisson$(\mu_b)$ upper tail is $\le p$. The Poisson arm is correct where the background is countable (a bright low-resolution ring gets a high threshold); the Gaussian arm — floored by a detector-level excess-noise constant $\sigma_\mathrm{read}$ — takes over on near-empty high-resolution rings, where the Poisson arm degenerates to "one photon is significant" and would flood. The operating point $p = E/N$ is set from a single portable knob $E$, the expected number of false pixels tolerated per frame (`--spot-false-pixels`, default 100), with $N$ the number of valid pixels. Because $p$ and every $\mu_b,\sigma_b$ come from the image itself, the same $E$ lands a sensible photon threshold on strong and weak datasets alike, with no per-dataset tuning. Rings too sparse to characterise (fewer than ~40 pixels) fall back to a whole-frame background. A pixel is strong when $v_i \ge t_b$ for its ring (saturated pixels are still forced strong); the strong pixels then feed the same CCL stage (§3.4). The signal-to-noise and photon-count criteria of §3.1 are not used in this mode. + +**Fused GPU engine.** The per-ring reduction the adaptive threshold needs is the *same* reduction the azimuthal integrator performs. On the GPU path the two are fused into a single image pass (`AdaptiveSpotFinderGPU`): one reduction accumulates the corrected per-ring sums for the azimuthal profile (§2) *and* the raw per-ring statistics for the threshold, after which a light kernel flags the strong pixels. This replaces both the separate azimuthal-integration pass and the host-side adaptive spot-finding pass with one GPU pass — on a ~4.5 MP detector it runs in ~1 ms/frame versus ~40 ms for the CPU adaptive finder, and produces an identical spot list and azimuthal profile. It is enabled by default in the offline `rugnux` path and the interactive viewer; the online receiver keeps the CPU adaptive finder. + +### 3.3 Resolution and ice-ring handling Spot finding can be restricted to a resolution range $[d_\mathrm{high}, d_\mathrm{low}]$ by masking pixels outside the range. Optionally, spots in identified ice-ring regions can be tagged so that subsequent indexing/refinement may include or exclude them (see §4 and §6). @@ -193,7 +207,7 @@ A single per-image **ice-ring score** is derived from the azimuthally-integrated A further optional safeguard removes isolated high-resolution “spur” spots by detecting large gaps in $1/d$ (or $q$) space and discarding spots beyond the gap. This is intended for macromolecular diffraction where edge-of-detector backgrounds can be extremely low. -### 3.3 Connected-component labeling (CCL) +### 3.4 Connected-component labeling (CCL) Strong pixels are grouped into connected components (adjacent strong pixels) using a CCL algorithm. Each component yields a candidate spot with: diff --git a/image_analysis/MXAnalysisWithoutFPGA.cpp b/image_analysis/MXAnalysisWithoutFPGA.cpp index bd256424..bf021e89 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.cpp +++ b/image_analysis/MXAnalysisWithoutFPGA.cpp @@ -13,11 +13,13 @@ #include "azint/AzIntEngineCPU.h" #include "roi/ROIIntegrationCPU.h" #include "spot_finding/ImageSpotFinderCPU.h" +#include "spot_finding/AdaptiveSpotFinderCPU.h" #include "bragg_integration/BraggIntegrationEngineCPU.h" #ifdef JFJOCH_USE_CUDA #include "azint/AzIntEngineGPU.h" #include "roi/ROIIntegrationGPU.h" #include "spot_finding/ImageSpotFinderGPU.h" +#include "spot_finding/AdaptiveSpotFinderGPU.h" #include "image_preprocessing/ImagePreprocessorGPU.h" #include "image_preprocessing/ImagePreprocessorBufferGPU.h" #include "bragg_integration/BraggIntegrationEngineGPU.h" @@ -28,9 +30,11 @@ MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_experiment, const AzimuthalIntegrationMapping &in_integration, const PixelMask &in_mask, - IndexAndRefine &in_indexer) + IndexAndRefine &in_indexer, + bool in_enable_fused_adaptive_gpu) : experiment(in_experiment), integration(in_integration), + enable_fused_adaptive_gpu(in_enable_fused_adaptive_gpu), npixels(experiment.GetPixelsNum()), xpixels(experiment.GetXPixelsNum()), indexer(in_indexer), @@ -59,9 +63,17 @@ MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_exp bragg_engine = std::make_unique(in_experiment, stream); if (experiment.ROI().size() >= 1) roi = std::make_unique(experiment, stream); + if (enable_fused_adaptive_gpu) { + // One GPU engine that computes the azimuthal profile and the adaptive spot mask in a single + // image pass. fused_adaptive aliases it so Analyze() can lift the profile out of it. + auto fused = std::make_unique(integration, stream); + fused_adaptive = fused.get(); + adaptiveSpotFinder = std::move(fused); + } } #endif - adaptiveSpotFinder = std::make_unique(integration); + if (!adaptiveSpotFinder) + adaptiveSpotFinder = std::make_unique(integration); } void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, @@ -83,10 +95,18 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, const auto preprocessing_end_time = std::chrono::steady_clock::now(); output.preprocessing_time_s = std::chrono::duration(preprocessing_end_time - preprocessing_start_time).count(); - const auto azint_start_time = std::chrono::steady_clock::now(); - azint->Run(*preprocessor_buffer, profile); - const auto azint_end_time = std::chrono::steady_clock::now(); - output.azint_time_s = std::chrono::duration(azint_end_time - azint_start_time).count(); + // The fused GPU engine (rugnux offline, GPU, adaptive detection) produces the azimuthal profile as + // a byproduct of spot finding, so the separate azint pass is skipped in that case and the profile is + // lifted out of the finder below. + const bool fused = enable_fused_adaptive_gpu && spot_finding_settings.enable + && spot_finding_settings.adaptive_threshold && fused_adaptive != nullptr; + + if (!fused) { + const auto azint_start_time = std::chrono::steady_clock::now(); + azint->Run(*preprocessor_buffer, profile); + const auto azint_end_time = std::chrono::steady_clock::now(); + output.azint_time_s = std::chrono::duration(azint_end_time - azint_start_time).count(); + } if (roi) roi->Run(*preprocessor_buffer, output.roi); @@ -106,6 +126,16 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, const auto spot_finding_end_time = std::chrono::steady_clock::now(); output.spot_finding_time_s = std::chrono::duration(spot_finding_end_time - spot_finding_start_time).count(); +#ifdef JFJOCH_USE_CUDA + if (fused) { + // Lift the azimuthal profile the fused engine computed in the same pass; its azint cost is + // folded into spot_finding_time_s above. + profile.Clear(integration); + profile += fused_adaptive->GetProfile(); + output.azint_time_s = 0.0f; + } +#endif + if (spot_finding_settings.indexing) indexer.ProcessImage(output, spot_finding_settings, *prediction, [this](const std::vector &predicted, size_t npredicted, int64_t image_number) { diff --git a/image_analysis/MXAnalysisWithoutFPGA.h b/image_analysis/MXAnalysisWithoutFPGA.h index 1b40f2f4..c79f473a 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.h +++ b/image_analysis/MXAnalysisWithoutFPGA.h @@ -22,6 +22,7 @@ #include "image_preprocessing/ImagePreprocessorBuffer.h" class CudaStream; +class AdaptiveSpotFinderGPU; // MXAnalysisWithoutFPGA is not thread safe - it has to owned by a single thread class MXAnalysisWithoutFPGA { @@ -38,10 +39,14 @@ class MXAnalysisWithoutFPGA { std::unique_ptr azint; std::unique_ptr roi; std::unique_ptr spotFinder; - // Self-calibrating CPU finder, used when spot settings request adaptive detection. Kept alongside - // the default finder because the choice arrives with the per-image settings, not at construction. - // It reads the host preprocessed image (populated on the GPU path too), so it works in either build. - std::unique_ptr adaptiveSpotFinder; + // Self-calibrating finder, used when spot settings request adaptive detection. Kept alongside the + // default finder because the choice arrives with the per-image settings, not at construction. It is + // an AdaptiveSpotFinderCPU by default; on the GPU path, when the fused engine is enabled (rugnux + // offline only), it is instead an AdaptiveSpotFinderGPU that also computes the azimuthal profile, + // aliased through fused_adaptive so Analyze() can take that profile and skip the separate azint pass. + std::unique_ptr adaptiveSpotFinder; + AdaptiveSpotFinderGPU *fused_adaptive = nullptr; + const bool enable_fused_adaptive_gpu; IndexAndRefine &indexer; std::unique_ptr prediction; std::unique_ptr bragg_engine; @@ -56,8 +61,12 @@ class MXAnalysisWithoutFPGA { std::shared_ptr stream; // kept so RebuildROI() can recreate the GPU ROI engine #endif public: + // enable_fused_adaptive_gpu turns on the fused GPU azint+adaptive spot finder (only takes effect on + // the GPU path with adaptive detection). The rugnux offline path and the interactive viewer enable + // it by default; the online receiver leaves it off and keeps the CPU adaptive finder + separate + // azint. It only changes performance - the fused engine reproduces the CPU finder's spots. MXAnalysisWithoutFPGA(const DiffractionExperiment &experiment, const AzimuthalIntegrationMapping &integration, - const PixelMask &mask, IndexAndRefine &indexer); + const PixelMask &mask, IndexAndRefine &indexer, bool enable_fused_adaptive_gpu = false); void Analyze(DataMessage &output, AzimuthalIntegrationProfile &profile, const SpotFindingSettings &spot_finding_settings); // Surgical ROI-only paths used when a full re-analysis is not wanted: rebuild the diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index a551f567..a722a67c 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -7,64 +7,7 @@ #include #include "AdaptiveSpotFinderCPU.h" - -namespace { - -// Number of background pixels a ring needs before its own statistics are trusted; sparser rings -// (detector corners, heavily masked, innermost) fall back to the whole-frame background. -constexpr int64_t MIN_RING_PIXELS = 40; - -// Inverse standard-normal CDF (Acklam's rational approximation, ~1e-9 accuracy). Only called once -// per frame, so accuracy over speed. -double NormalQuantile(double p) { - if (p <= 0.0) return -40.0; - if (p >= 1.0) return 40.0; - static const double a[] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, - 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00}; - static const double b[] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, - 6.680131188771972e+01, -1.328068155288572e+01}; - static const double c[] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, - -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00}; - static const double d[] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, - 3.754408661907416e+00}; - const double plow = 0.02425, phigh = 1.0 - 0.02425; - if (p < plow) { - double q = std::sqrt(-2.0 * std::log(p)); - return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / - ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); - } else if (p <= phigh) { - double q = p - 0.5, r = q*q; - return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / - (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0); - } else { - double q = std::sqrt(-2.0 * std::log(1.0 - p)); - return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / - ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); - } -} - -// Smallest integer count whose Poisson(mu) upper tail P(X >= k) <= p. This is the correct -// significance floor while the background is countable (it carries the sqrt(mu) shot-noise -// implicitly, so a bright low-resolution ring gets a high threshold). It DEGENERATES at mu -> 0 -// (a single photon on a zero background is "significant"), which is why it is max'd with a -// read-noise-floored Gaussian arm by the caller. Short-circuits to Gaussian for large mu. -float PoissonThreshold(double mu, double p, double z) { - if (mu > 50.0) - return static_cast(mu + z * std::sqrt(mu)); - if (mu < 1e-6) mu = 1e-6; - const double target = 1.0 - p; - double pmf = std::exp(-mu); - double cdf = pmf; - int k = 0; - while (cdf < target && k < 1000) { - ++k; - pmf *= mu / k; - cdf += pmf; - } - return static_cast(k + 1); -} - -} // namespace +#include "AdaptiveThreshold.h" AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &in_mapping) : ImageSpotFinder(static_cast(in_mapping.GetWidth()), @@ -142,32 +85,18 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB const double E = std::max(1.0f, settings.false_pixels_per_frame); double p = E / static_cast(n_total); p = std::min(std::max(p, 1e-9), 0.1); - const float z = static_cast(NormalQuantile(1.0 - p)); - - // A ring's threshold is background mean + z sigmas. sigma combines the ring's own (peak-excluded) - // scatter with an excess-noise floor READ: near-zero-background rings scatter MORE than pure - // Poisson (charge sharing / read noise / occasional spurious low counts), so a per-ring sigma - // alone collapses toward zero on empty high-resolution rings and the threshold would flood. READ - // is a detector-level photon-scale constant (the same for every dataset -- it is NOT the - // per-dataset knob), so the operating point still self-calibrates through mean and sigma while - // staying physical where the background vanishes. - const float READ = 1.0f; - auto ring_threshold = [&](float mean, float sigma) { - // Poisson significance (correct where the background is countable) floored by a - // read-noise-aware Gaussian arm (which alone survives mean -> 0, where Poisson degenerates - // to "one photon is significant" and would flood the empty high-resolution rings). - const float gauss = mean + z * std::sqrt(sigma * sigma + READ * READ); - const float poisson = PoissonThreshold(mean, static_cast(p), static_cast(z)); - return std::max(gauss, poisson); - }; + const float z = static_cast(adaptive_threshold::NormalQuantile(1.0 - p)); // whole-frame fallback background for rings too sparse to trust on their own const double g_mean = g_sum / n_total; const double g_sigma = std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * g_mean)); - const float g_thr = ring_threshold(static_cast(g_mean), static_cast(g_sigma)); + const float g_thr = adaptive_threshold::RingThreshold(static_cast(g_mean), + static_cast(g_sigma), p, z); for (size_t b = 0; b < nbins; ++b) - ring_thr[b] = (ring_cnt[b] < MIN_RING_PIXELS) ? g_thr : ring_threshold(ring_mean[b], ring_sigma[b]); + ring_thr[b] = (ring_cnt[b] < adaptive_threshold::MIN_RING_PIXELS) + ? g_thr + : adaptive_threshold::RingThreshold(ring_mean[b], ring_sigma[b], p, z); // --- Stage C: flag strong pixels into the bit buffer (value >= ring threshold) --- for (size_t i = 0; i < OutputSize(); ++i) diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu new file mode 100644 index 00000000..4403bde3 --- /dev/null +++ b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "AdaptiveSpotFinderGPU.h" +#include "AdaptiveThreshold.h" +#include "../../common/JFJochException.h" + +namespace { + +inline void cuda_err(cudaError_t val) { + if (val != cudaSuccess) + throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val)); +} + +// One ring reduction, staging per-ring sums in shared memory (fast path). Shared layout: +// [ sum(float) | sum2(float) | count(uint32) | sum_corr(float) | sum2_corr(float) ] x nbins +// The corrected arrays exist only when accumulate_corrected is true (the plain first pass); on the +// sigma-clip passes only the first three are launched/used. +__global__ void reduce_rings_shared( + const uint16_t *__restrict__ pixel_to_bin, + const float *__restrict__ corrections, + const int32_t *__restrict__ image, + const float *__restrict__ mean, + const float *__restrict__ sigma, + float clip_k, + bool accumulate_corrected, + float *__restrict__ sum, float *__restrict__ sum2, uint32_t *__restrict__ count, + float *__restrict__ sum_corr, float *__restrict__ sum2_corr, + size_t npix, int nbins) { + + extern __shared__ float sh[]; + float *s_sum = sh; + float *s_sum2 = &s_sum[nbins]; + uint32_t *s_count = reinterpret_cast(&s_sum2[nbins]); + float *s_sum_corr = reinterpret_cast(&s_count[nbins]); + float *s_sum2_corr = &s_sum_corr[nbins]; + + for (int i = threadIdx.x; i < nbins; i += blockDim.x) { + s_sum[i] = 0.0f; + s_sum2[i] = 0.0f; + s_count[i] = 0; + if (accumulate_corrected) { + s_sum_corr[i] = 0.0f; + s_sum2_corr[i] = 0.0f; + } + } + __syncthreads(); + + for (size_t idx = blockIdx.x * blockDim.x + threadIdx.x; idx < npix; idx += blockDim.x * gridDim.x) { + const int32_t v = image[idx]; + if (v == INT32_MIN || v == INT32_MAX) continue; + const uint16_t b = pixel_to_bin[idx]; + if (b >= nbins) continue; + const float fv = static_cast(v); + if (clip_k > 0.0f) { + const float lo = mean[b] - clip_k * sigma[b]; + const float hi = mean[b] + clip_k * sigma[b]; + if (fv < lo || fv > hi) continue; + } + atomicAdd(&s_sum[b], fv); + atomicAdd(&s_sum2[b], fv * fv); + atomicAdd(&s_count[b], 1u); + if (accumulate_corrected) { + const float cv = fv * corrections[idx]; + atomicAdd(&s_sum_corr[b], cv); + atomicAdd(&s_sum2_corr[b], cv * cv); + } + } + __syncthreads(); + + for (int i = threadIdx.x; i < nbins; i += blockDim.x) { + atomicAdd(&sum[i], s_sum[i]); + atomicAdd(&sum2[i], s_sum2[i]); + atomicAdd(&count[i], s_count[i]); + if (accumulate_corrected) { + atomicAdd(&sum_corr[i], s_sum_corr[i]); + atomicAdd(&sum2_corr[i], s_sum2_corr[i]); + } + } +} + +// Same reduction with direct global atomics (used only when nbins is too large to stage in shared +// memory - a rare, high-bin-count configuration). +__global__ void reduce_rings_global( + const uint16_t *__restrict__ pixel_to_bin, + const float *__restrict__ corrections, + const int32_t *__restrict__ image, + const float *__restrict__ mean, + const float *__restrict__ sigma, + float clip_k, + bool accumulate_corrected, + float *__restrict__ sum, float *__restrict__ sum2, uint32_t *__restrict__ count, + float *__restrict__ sum_corr, float *__restrict__ sum2_corr, + size_t npix, int nbins) { + + for (size_t idx = blockIdx.x * blockDim.x + threadIdx.x; idx < npix; idx += blockDim.x * gridDim.x) { + const int32_t v = image[idx]; + if (v == INT32_MIN || v == INT32_MAX) continue; + const uint16_t b = pixel_to_bin[idx]; + if (b >= nbins) continue; + const float fv = static_cast(v); + if (clip_k > 0.0f) { + const float lo = mean[b] - clip_k * sigma[b]; + const float hi = mean[b] + clip_k * sigma[b]; + if (fv < lo || fv > hi) continue; + } + atomicAdd(&sum[b], fv); + atomicAdd(&sum2[b], fv * fv); + atomicAdd(&count[b], 1u); + if (accumulate_corrected) { + const float cv = fv * corrections[idx]; + atomicAdd(&sum_corr[b], cv); + atomicAdd(&sum2_corr[b], cv * cv); + } + } +} + +// Per-ring mean/sigma from the current raw accumulators. Rings with no pixels this pass keep their +// previous value (matches the CPU, which leaves ring_mean/ring_sigma untouched when the count is 0). +__global__ void finalize_rings(const float *__restrict__ sum, const float *__restrict__ sum2, + const uint32_t *__restrict__ count, + float *__restrict__ mean, float *__restrict__ sigma, int nbins) { + for (int b = blockIdx.x * blockDim.x + threadIdx.x; b < nbins; b += blockDim.x * gridDim.x) { + if (count[b] > 0) { + const float m = sum[b] / count[b]; + const float var = fmaxf(0.0f, sum2[b] / count[b] - m * m); + mean[b] = m; + sigma[b] = sqrtf(var); + } + } +} + +// Flag strong pixels (value >= ring threshold, or saturated) into the packed bit buffer. Strong +// pixels are sparse, so a plain atomicOr per strong pixel is simpler than warp aggregation and the +// contention is negligible. Mirrors AdaptiveSpotFinderCPU Stage C exactly. +__global__ void flag_strong(const int32_t *__restrict__ image, + const uint16_t *__restrict__ pixel_to_bin, + const float *__restrict__ thr, + uint32_t *__restrict__ strong, + size_t npix, int nbins) { + for (size_t idx = blockIdx.x * blockDim.x + threadIdx.x; idx < npix; idx += blockDim.x * gridDim.x) { + const int32_t v = image[idx]; + bool s = false; + if (v == INT32_MAX) { + s = true; + } else if (v != INT32_MIN) { + const uint16_t b = pixel_to_bin[idx]; + if (b < nbins && static_cast(v) >= thr[b]) + s = true; + } + if (s) + atomicOr(&strong[idx / 32], 1u << (idx % 32)); + } +} + +} // namespace + +AdaptiveSpotFinderGPU::AdaptiveSpotFinderGPU(const AzimuthalIntegrationMapping &in_mapping, + std::shared_ptr in_stream) + : ImageSpotFinder(static_cast(in_mapping.GetWidth()), + static_cast(in_mapping.GetHeight())), + mapping(in_mapping), + stream(std::move(in_stream)), + nbins(in_mapping.GetBinNumber()), + npix(in_mapping.GetPixelToBin().size()), + gpu_pixel_to_bin(npix), + gpu_corrections(npix), + gpu_sum(nbins), + gpu_sum2(nbins), + gpu_count(nbins), + gpu_mean(nbins), + gpu_sigma(nbins), + gpu_sum_corr(nbins), + gpu_sum2_corr(nbins), + gpu_thr(nbins), + gpu_strong(OutputSize()), + host_sum(nbins), + host_sum2(nbins), + host_count(nbins), + prof_sum(nbins), + prof_sum2(nbins), + prof_count(nbins), + output_buffer_reg(output_buffer), + last_profile(in_mapping) { + + cudaDeviceProp prop{}; + cuda_err(cudaGetDeviceProperties(&prop, 0)); + reduce_blocks = 4 * prop.multiProcessorCount; + flag_blocks = 4 * prop.multiProcessorCount; + + shared_plain = static_cast(nbins) * (4 * sizeof(float) + sizeof(uint32_t)); + shared_clip = static_cast(nbins) * (2 * sizeof(float) + sizeof(uint32_t)); + use_shared = (shared_plain < prop.sharedMemPerBlock); + + cuda_err(cudaMemcpy(gpu_pixel_to_bin, mapping.GetPixelToBin().data(), sizeof(uint16_t) * npix, + cudaMemcpyHostToDevice)); + cuda_err(cudaMemcpy(gpu_corrections, mapping.Corrections().data(), sizeof(float) * npix, + cudaMemcpyHostToDevice)); +} + +void AdaptiveSpotFinderGPU::ReducePass(const ImagePreprocessorBuffer &image, float clip_k, + bool accumulate_corrected) { + if (use_shared) { + const size_t shared = accumulate_corrected ? shared_plain : shared_clip; + reduce_rings_shared<<>>( + gpu_pixel_to_bin, gpu_corrections, image.getGPUBuffer(), gpu_mean, gpu_sigma, + clip_k, accumulate_corrected, gpu_sum, gpu_sum2, gpu_count, gpu_sum_corr, gpu_sum2_corr, + npix, nbins); + } else { + reduce_rings_global<<>>( + gpu_pixel_to_bin, gpu_corrections, image.getGPUBuffer(), gpu_mean, gpu_sigma, + clip_k, accumulate_corrected, gpu_sum, gpu_sum2, gpu_count, gpu_sum_corr, gpu_sum2_corr, + npix, nbins); + } +} + +void AdaptiveSpotFinderGPU::FinalizeStats() { + const int threads = 128; + const int blocks = (nbins + threads - 1) / threads; + finalize_rings<<>>(gpu_sum, gpu_sum2, gpu_count, gpu_mean, gpu_sigma, nbins); +} + +// Host reproduction of AdaptiveSpotFinderCPU Stage B, from the clipped raw per-ring stats. +void AdaptiveSpotFinderGPU::ComputeThresholds(const SpotFindingSettings &settings) { + int64_t n_total = 0; + double g_sum = 0.0, g_sum2 = 0.0; + for (int b = 0; b < nbins; ++b) { + n_total += host_count[b]; + g_sum += host_sum[b]; + g_sum2 += host_sum2[b]; + } + if (n_total == 0) { + host_thr.clear(); + return; + } + + const double E = std::max(1.0f, settings.false_pixels_per_frame); + double p = E / static_cast(n_total); + p = std::min(std::max(p, 1e-9), 0.1); + const float z = static_cast(adaptive_threshold::NormalQuantile(1.0 - p)); + + const double g_mean = g_sum / n_total; + const double g_sigma = std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * g_mean)); + const float g_thr = adaptive_threshold::RingThreshold(static_cast(g_mean), + static_cast(g_sigma), p, z); + + host_thr.assign(nbins, 0.0f); + for (int b = 0; b < nbins; ++b) { + if (host_count[b] < adaptive_threshold::MIN_RING_PIXELS) { + host_thr[b] = g_thr; + } else { + const double m = static_cast(host_sum[b]) / host_count[b]; + const double var = std::max(0.0, static_cast(host_sum2[b]) / host_count[b] - m * m); + host_thr[b] = adaptive_threshold::RingThreshold(static_cast(m), + static_cast(std::sqrt(var)), p, z); + } + } +} + +std::vector AdaptiveSpotFinderGPU::Run(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) { + if (image.size() != npix) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "AdaptiveSpotFinderGPU::Run: mismatch in pixel size"); + + // --- Stage A: robust per-ring background (one plain pass + two sigma-clip passes) --- + cuda_err(cudaMemsetAsync(gpu_sum, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_sum2, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_count, 0, sizeof(uint32_t) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_mean, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_sigma, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_sum_corr, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_sum2_corr, 0, sizeof(float) * nbins, *stream)); + + ReducePass(image, 0.0f, true); // plain pass also fills the corrected profile accumulators + FinalizeStats(); + + // Snapshot the plain corrected profile (and its pixel count) before the raw accumulators are + // re-zeroed for the sigma-clip passes. + cuda_err(cudaMemcpyAsync(prof_sum.data(), gpu_sum_corr, sizeof(float) * nbins, cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaMemcpyAsync(prof_sum2.data(), gpu_sum2_corr, sizeof(float) * nbins, cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaMemcpyAsync(prof_count.data(), gpu_count, sizeof(uint32_t) * nbins, cudaMemcpyDeviceToHost, *stream)); + + for (int pass = 0; pass < 2; ++pass) { + cuda_err(cudaMemsetAsync(gpu_sum, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_sum2, 0, sizeof(float) * nbins, *stream)); + cuda_err(cudaMemsetAsync(gpu_count, 0, sizeof(uint32_t) * nbins, *stream)); + ReducePass(image, 3.0f, false); + FinalizeStats(); + } + + // Snapshot the clipped raw stats that drive the threshold. + cuda_err(cudaMemcpyAsync(host_sum.data(), gpu_sum, sizeof(float) * nbins, cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaMemcpyAsync(host_sum2.data(), gpu_sum2, sizeof(float) * nbins, cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaMemcpyAsync(host_count.data(), gpu_count, sizeof(uint32_t) * nbins, cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaStreamSynchronize(*stream)); + + // --- Stage B: per-ring threshold on the host (shared with the CPU finder) --- + ComputeThresholds(settings); + + // The profile is a byproduct even when the frame has no valid pixels for detection. + last_profile.Clear(mapping); + last_profile.Add(prof_sum, prof_sum2, prof_count); + + if (host_thr.empty()) + return {}; + + // --- Stage C: flag strong pixels into the bit buffer (value >= ring threshold) --- + cuda_err(cudaMemcpyAsync(gpu_thr, host_thr.data(), sizeof(float) * nbins, cudaMemcpyHostToDevice, *stream)); + cuda_err(cudaMemsetAsync(gpu_strong, 0, OutputByteSize(), *stream)); + flag_strong<<>>( + image.getGPUBuffer(), gpu_pixel_to_bin, gpu_thr, gpu_strong, npix, nbins); + cuda_err(cudaMemcpyAsync(output_buffer.data(), gpu_strong, OutputByteSize(), cudaMemcpyDeviceToHost, *stream)); + cuda_err(cudaStreamSynchronize(*stream)); + + // --- Stage D: connected components + resolution mask + min/max-pix (shared host path) --- + return ExtractSpots(image, settings, res_mask); +} diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h new file mode 100644 index 00000000..e115ca8f --- /dev/null +++ b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +// GPU adaptive spot finder that FUSES azimuthal integration and spot finding into one image pass. +// +// The CPU adaptive finder (AdaptiveSpotFinderCPU) and the azimuthal integrator both bin every pixel +// into resolution rings and reduce (sum / sum^2 / count). Today azint runs on the GPU while the +// adaptive finder re-does the identical per-ring reduction on the HOST - a wasted second pass over a +// ~10 MP image. This engine does the ring reduction on the GPU and drives BOTH products from it: +// - the azimuthal-integration profile (mean intensity per ring, in flat-field-corrected space), and +// - the per-ring background (mean, sigma, peak-excluded via two sigma-clip passes) that sets the +// self-calibrating spot-detection threshold (in raw photon counts). +// It then flags strong pixels (value >= ring threshold) into a packed bit buffer and hands it to the +// shared host connected-component extractor (ImageSpotFinder::ExtractSpots). +// +// Numerically it reproduces AdaptiveSpotFinderCPU: the same three-pass robust background, the same +// per-ring threshold formula (shared via AdaptiveThreshold.h, computed on the host once per frame), +// and the same raw-count detection test. The only differences from the CPU are those inherent to a +// GPU reduction (float per-ring accumulation in atomic order vs the CPU's serial double sums), which +// shift a handful of borderline pixels at most. The corrected sums for the azint profile are +// accumulated in the SAME plain first pass, so one reduction feeds both products. + +#include +#include + +#include "ImageSpotFinder.h" +#include "SpotFindingSettings.h" +#include "../../common/AzimuthalIntegrationProfile.h" +#include "../../common/AzimuthalIntegrationMapping.h" +#include "../indexing/CUDAMemHelpers.h" + +class AdaptiveSpotFinderGPU : public ImageSpotFinder { + const AzimuthalIntegrationMapping &mapping; + std::shared_ptr stream; + + const int nbins; + const size_t npix; + + int reduce_threads = 128; + int reduce_blocks = 0; + int flag_threads = 256; + int flag_blocks = 0; + size_t shared_plain = 0; // per-block shared bytes for the plain pass (raw + corrected rings) + size_t shared_clip = 0; // per-block shared bytes for a sigma-clip pass (raw rings only) + bool use_shared = true; // false -> nbins too large for shared memory, use the global-atomics kernel + + // Static mapping inputs (uploaded once). + CudaDevicePtr gpu_pixel_to_bin; + CudaDevicePtr gpu_corrections; + + // Raw per-ring accumulators (re-zeroed each pass) + derived stats used to clip and threshold. + CudaDevicePtr gpu_sum; + CudaDevicePtr gpu_sum2; + CudaDevicePtr gpu_count; + CudaDevicePtr gpu_mean; // per-ring raw mean (clip predicate) + CudaDevicePtr gpu_sigma; // per-ring raw sigma (clip predicate) + + // Corrected per-ring accumulators (plain first pass only) -> azimuthal-integration profile. + CudaDevicePtr gpu_sum_corr; + CudaDevicePtr gpu_sum2_corr; + + // Per-ring detection threshold (host-computed, uploaded) and the strong-pixel bit buffer. + CudaDevicePtr gpu_thr; + CudaDevicePtr gpu_strong; + + // Host mirrors of the small per-ring transfers. + std::vector host_sum; // clipped raw sum } input to the host threshold computation + std::vector host_sum2; // clipped raw sum^2 } + std::vector host_count; // clipped raw count } + std::vector host_thr; // per-ring threshold (empty -> frame had no valid pixels) + std::vector prof_sum; // plain corrected sum } azimuthal-integration profile + std::vector prof_sum2; // plain corrected sum^2 } + std::vector prof_count; // plain pixel count } + + CudaRegisteredVector output_buffer_reg; // pins the base-class bit buffer for fast D2H + + AzimuthalIntegrationProfile last_profile; // filled every Run(), retrievable via GetProfile() + + // One reduction pass over the image into the raw accumulators. clip_k <= 0 -> plain pass (all + // valid pixels); clip_k > 0 -> keep only pixels within clip_k sigma of the current gpu_mean. + // accumulate_corrected additionally fills gpu_sum_corr/gpu_sum2_corr for the profile (plain pass). + void ReducePass(const ImagePreprocessorBuffer &image, float clip_k, bool accumulate_corrected); + // Finalize gpu_mean/gpu_sigma from the current raw accumulators (per ring). + void FinalizeStats(); + // Host: per-ring threshold from the clipped raw stats and the single knob E (false pixels/frame). + void ComputeThresholds(const SpotFindingSettings &settings); + +public: + AdaptiveSpotFinderGPU(const AzimuthalIntegrationMapping &mapping, std::shared_ptr stream); + ~AdaptiveSpotFinderGPU() override = default; + AdaptiveSpotFinderGPU(const AdaptiveSpotFinderGPU &) = delete; + AdaptiveSpotFinderGPU &operator=(const AdaptiveSpotFinderGPU &) = delete; + + std::vector Run(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) override; + + // The azimuthal profile computed as a byproduct of the last Run() - lets this engine replace the + // separate azint pass in the analysis pipeline. + [[nodiscard]] const AzimuthalIntegrationProfile &GetProfile() const { return last_profile; } +}; diff --git a/image_analysis/spot_finding/AdaptiveThreshold.h b/image_analysis/spot_finding/AdaptiveThreshold.h new file mode 100644 index 00000000..047ee469 --- /dev/null +++ b/image_analysis/spot_finding/AdaptiveThreshold.h @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +// Per-resolution-ring detection-threshold math shared by the CPU adaptive spot finder +// (AdaptiveSpotFinderCPU) and its GPU fused counterpart (AdaptiveSpotFinderGPU). Both engines reduce +// every pixel into resolution rings, take a robust per-ring background (mean, sigma), and turn it into +// a strong-pixel threshold with the SAME formula - so keeping that formula in one place is what makes +// the GPU engine reproduce the CPU one. These are plain host functions (the threshold is computed on +// the host in both engines, once per frame, over the small per-ring arrays). + +#include +#include +#include + +namespace adaptive_threshold { + +// Number of background pixels a ring needs before its own statistics are trusted; sparser rings +// (detector corners, heavily masked, innermost) fall back to the whole-frame background. +constexpr int64_t MIN_RING_PIXELS = 40; + +// Detector-level excess-noise floor (photons). Near-zero-background rings scatter MORE than pure +// Poisson (charge sharing / read noise / occasional spurious low counts), so a per-ring sigma alone +// collapses toward zero on empty high-resolution rings and the threshold would flood. READ is a +// photon-scale constant (the same for every dataset -- NOT the per-dataset knob), so the operating +// point still self-calibrates through mean and sigma while staying physical where the background +// vanishes. +constexpr float READ = 1.0f; + +// Inverse standard-normal CDF (Acklam's rational approximation, ~1e-9 accuracy). Only called once +// per frame, so accuracy over speed. +inline double NormalQuantile(double p) { + if (p <= 0.0) return -40.0; + if (p >= 1.0) return 40.0; + static const double a[] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, + 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00}; + static const double b[] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, + 6.680131188771972e+01, -1.328068155288572e+01}; + static const double c[] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, + -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00}; + static const double d[] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, + 3.754408661907416e+00}; + const double plow = 0.02425, phigh = 1.0 - 0.02425; + if (p < plow) { + double q = std::sqrt(-2.0 * std::log(p)); + return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / + ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); + } else if (p <= phigh) { + double q = p - 0.5, r = q*q; + return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / + (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0); + } else { + double q = std::sqrt(-2.0 * std::log(1.0 - p)); + return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / + ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); + } +} + +// Smallest integer count whose Poisson(mu) upper tail P(X >= k) <= p. This is the correct +// significance floor while the background is countable (it carries the sqrt(mu) shot-noise +// implicitly, so a bright low-resolution ring gets a high threshold). It DEGENERATES at mu -> 0 +// (a single photon on a zero background is "significant"), which is why it is max'd with a +// read-noise-floored Gaussian arm by the caller. Short-circuits to Gaussian for large mu. +inline float PoissonThreshold(double mu, double p, double z) { + if (mu > 50.0) + return static_cast(mu + z * std::sqrt(mu)); + if (mu < 1e-6) mu = 1e-6; + const double target = 1.0 - p; + double pmf = std::exp(-mu); + double cdf = pmf; + int k = 0; + while (cdf < target && k < 1000) { + ++k; + pmf *= mu / k; + cdf += pmf; + } + return static_cast(k + 1); +} + +// A ring's threshold is background mean + z sigmas, computed two ways and max'd: Poisson significance +// (correct where the background is countable) floored by a read-noise-aware Gaussian arm (which alone +// survives mean -> 0, where Poisson degenerates to "one photon is significant" and would flood the +// empty high-resolution rings). p, z are the frame-wide operating point (p = E / N_pixels). +inline float RingThreshold(float mean, float sigma, double p, float z) { + const float gauss = mean + z * std::sqrt(sigma * sigma + READ * READ); + const float poisson = PoissonThreshold(static_cast(mean), p, static_cast(z)); + return std::max(gauss, poisson); +} + +} // namespace adaptive_threshold diff --git a/image_analysis/spot_finding/CMakeLists.txt b/image_analysis/spot_finding/CMakeLists.txt index 0a94ca7d..59c39afb 100644 --- a/image_analysis/spot_finding/CMakeLists.txt +++ b/image_analysis/spot_finding/CMakeLists.txt @@ -16,5 +16,7 @@ ADD_LIBRARY(JFJochSpotFinding STATIC TARGET_LINK_LIBRARIES(JFJochSpotFinding JFJochCommon) IF (JFJOCH_CUDA_AVAILABLE) - TARGET_SOURCES(JFJochSpotFinding PRIVATE ImageSpotFinderGPU.cu ImageSpotFinderGPU.h) + TARGET_SOURCES(JFJochSpotFinding PRIVATE + ImageSpotFinderGPU.cu ImageSpotFinderGPU.h + AdaptiveSpotFinderGPU.cu AdaptiveSpotFinderGPU.h) ENDIF() \ No newline at end of file diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 9dacc5d9..b075daaf 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -122,7 +122,8 @@ void Rugnux::RefineStillsGeometry(int start_image, int end_image, int images_to_ auto worker = [&]() { pin_gpu(); // round-robin per worker thread; must precede engine construction - MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, indexer); + MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, indexer, + /*enable_fused_adaptive_gpu=*/true); AzimuthalIntegrationProfile profile(mapping); while (!cancelled_) { @@ -459,7 +460,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b DataMessage m{}; m.number = ordinal; m.original_number = image_idx; - MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, *indexer); + MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, *indexer, + /*enable_fused_adaptive_gpu=*/true); AzimuthalIntegrationProfile profile(mapping); auto first_pass = config_.spot_finding; first_pass.indexing = false; @@ -729,7 +731,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b auto full_worker = [&]() { pin_gpu(); // round-robin per worker thread; must precede engine construction - MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, *indexer); + MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, *indexer, + /*enable_fused_adaptive_gpu=*/true); AzimuthalIntegrationProfile profile(mapping); while (!cancelled_) { diff --git a/tests/AdaptiveSpotFinderGPUTest.cpp b/tests/AdaptiveSpotFinderGPUTest.cpp new file mode 100644 index 00000000..2b86e994 --- /dev/null +++ b/tests/AdaptiveSpotFinderGPUTest.cpp @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include +#include "../common/CUDAWrapper.h" + +#ifdef JFJOCH_USE_CUDA + +#include +#include + +#include "../common/AzimuthalIntegrationMapping.h" +#include "../common/AzimuthalIntegrationProfile.h" +#include "../image_analysis/azint/AzIntEngineGPU.h" +#include "../image_analysis/spot_finding/AdaptiveSpotFinderCPU.h" +#include "../image_analysis/spot_finding/AdaptiveSpotFinderGPU.h" +#include "../image_analysis/spot_finding/ImageSpotFinderGPU.h" +#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h" + +namespace { + +// Build a realistic full-detector azimuthal-integration mapping (JF4M, ~4.5 MP) whose q-range spans +// most of the detector, so the timing runs over a representative pixel count. +DiffractionExperiment MakeExperiment() { + DiffractionExperiment x(DetJF4M()); + x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080); + x.QSpacingForAzimInt_recipA(0.05).QRangeForAzimInt_recipA(0.05, 5.0); + return x; +} + +// Deterministic image: a low, slightly rippled background (well below any adaptive threshold) plus a +// grid of bright multi-pixel blobs that both finders must recover identically. +void FillTestImage(ImagePreprocessorBuffer &buffer, const DiffractionExperiment &x) { + const size_t w = x.GetXPixelsNum(); + const size_t h = x.GetYPixelsNum(); + for (size_t i = 0; i < w * h; i++) + buffer[i] = 8 + static_cast(i % 5); // background 8..12 (mean 10) + + // Bright 3x3 blobs on a coarse grid, kept clear of the edges and the beam centre. + for (size_t row = 300; row < h - 300; row += 450) { + for (size_t col = 300; col < w - 300; col += 450) { + for (int dr = -1; dr <= 1; dr++) + for (int dc = -1; dc <= 1; dc++) + buffer[(row + dr) * w + (col + dc)] = 200; + } + } +} + +SpotFindingSettings AdaptiveSettings() { + SpotFindingSettings s{}; + s.adaptive_threshold = true; + s.false_pixels_per_frame = 100.0f; + s.min_pix_per_spot = 1; + s.max_pix_per_spot = 50; + s.high_resolution_limit = 0.0f; // no resolution gate for the parity test + s.low_resolution_limit = 1.0e6f; + s.high_res_gap_Q_recipA = std::nullopt; + return s; +} + +std::vector> SortedCoords(const std::vector &spots) { + std::vector> out; + out.reserve(spots.size()); + for (const auto &s : spots) + out.emplace_back(static_cast(std::lround(s.RawCoord().y)), + static_cast(std::lround(s.RawCoord().x))); + std::sort(out.begin(), out.end()); + return out; +} + +} // namespace + +// Spot-finding functionality: the fused GPU engine must reproduce the reference CPU adaptive finder's +// spot list (the two share AdaptiveThreshold.h and the host connected-component extractor; the only +// difference is the GPU's float atomic ring reduction, which is exact for a realistic background). +TEST_CASE("AdaptiveSpotFinderGPU_SpotFindingParity", "[AdaptiveSpotFinderGPU]") { + if (get_gpu_count() == 0) { + WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_SpotFindingParity"); + return; + } + + DiffractionExperiment x = MakeExperiment(); + PixelMask pixel_mask(x); + AzimuthalIntegrationMapping mapping(x, pixel_mask); + + ImagePreprocessorBufferGPU buffer(x.GetPixelsNum()); + FillTestImage(buffer, x); + REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(), + x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess); + + std::vector res_mask(x.GetPixelsNum(), false); + const SpotFindingSettings settings = AdaptiveSettings(); + + AdaptiveSpotFinderCPU cpu(mapping); + auto stream = std::make_shared(); + AdaptiveSpotFinderGPU gpu(mapping, stream); + + const auto cpu_spots = cpu.Run(buffer, settings, res_mask); + const auto gpu_spots = gpu.Run(buffer, settings, res_mask); + + INFO("cpu spots=" << cpu_spots.size() << " gpu spots=" << gpu_spots.size()); + REQUIRE(cpu_spots.size() > 0); + REQUIRE(cpu_spots.size() == gpu_spots.size()); + CHECK(SortedCoords(cpu_spots) == SortedCoords(gpu_spots)); +} + +// Azimuthal-integration functionality: the profile the fused engine computes as a byproduct of the +// same pass must match a standalone GPU azimuthal integrator over the same image. +TEST_CASE("AdaptiveSpotFinderGPU_AzimuthalIntegration", "[AdaptiveSpotFinderGPU]") { + if (get_gpu_count() == 0) { + WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_AzimuthalIntegration"); + return; + } + + DiffractionExperiment x = MakeExperiment(); + PixelMask pixel_mask(x); + AzimuthalIntegrationMapping mapping(x, pixel_mask); + + ImagePreprocessorBufferGPU buffer(x.GetPixelsNum()); + FillTestImage(buffer, x); + REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(), + x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess); + + std::vector res_mask(x.GetPixelsNum(), false); + const SpotFindingSettings settings = AdaptiveSettings(); + + auto stream = std::make_shared(); + AdaptiveSpotFinderGPU gpu(mapping, stream); + gpu.Run(buffer, settings, res_mask); + + AzIntEngineGPU azint(mapping, stream); + AzimuthalIntegrationProfile ref_profile(mapping); + azint.Run(buffer, ref_profile); + + const auto ref = ref_profile.GetResult(); + const auto got = gpu.GetProfile().GetResult(); + const auto ref_count = ref_profile.GetPixelCount(); + const auto got_count = gpu.GetProfile().GetPixelCount(); + REQUIRE(ref.size() == got.size()); + REQUIRE(ref_count == got_count); // identical per-ring pixel counts (same valid-pixel binning) + for (size_t b = 0; b < ref.size(); b++) { + if (std::isnan(ref[b])) { + CHECK(std::isnan(got[b])); + } else { + CHECK(got[b] == Catch::Approx(ref[b]).epsilon(0.01).margin(0.02)); + } + } +} + +TEST_CASE("AdaptiveSpotFinderGPU_Speed", "[AdaptiveSpotFinderGPU][.benchmark]") { + if (get_gpu_count() == 0) { + WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_Speed"); + return; + } + + DiffractionExperiment x = MakeExperiment(); + PixelMask pixel_mask(x); + AzimuthalIntegrationMapping mapping(x, pixel_mask); + + ImagePreprocessorBufferGPU buffer(x.GetPixelsNum()); + FillTestImage(buffer, x); + REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(), + x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess); + + std::vector res_mask(x.GetPixelsNum(), false); + const SpotFindingSettings settings = AdaptiveSettings(); + + auto stream = std::make_shared(); + AdaptiveSpotFinderCPU cpu(mapping); + AdaptiveSpotFinderGPU gpu_fused(mapping, stream); + ImageSpotFinderGPU gpu_classic(x.GetXPixelsNum(), x.GetYPixelsNum(), stream); + AzIntEngineGPU azint(mapping, stream); + AzimuthalIntegrationProfile profile(mapping); + + const int warmup = 5, iters = 40; + auto bench = [&](const char *name, auto &&fn) { + for (int i = 0; i < warmup; i++) fn(); + const auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < iters; i++) fn(); + const auto t1 = std::chrono::steady_clock::now(); + const double ms = std::chrono::duration(t1 - t0).count() / iters; + WARN(name << ": " << ms << " ms/frame"); + return ms; + }; + + const double t_azint = bench("GPU azint (standalone)", [&] { azint.Run(buffer, profile); }); + const double t_cpu = bench("CPU adaptive spot finding", [&] { cpu.Run(buffer, settings, res_mask); }); + const double t_classic = bench("GPU classic spot finding (local-box)", [&] { gpu_classic.Run(buffer, settings, res_mask); }); + const double t_fused = bench("GPU adaptive FUSED (azint + spot finding)", [&] { gpu_fused.Run(buffer, settings, res_mask); }); + + WARN("standard adaptive path (GPU azint + CPU adaptive) = " << (t_azint + t_cpu) + << " ms/frame vs fused GPU = " << t_fused << " ms/frame (speedup " + << (t_azint + t_cpu) / t_fused << "x)"); + WARN("fused GPU vs GPU classic finder alone (no azint): " << t_fused << " vs " << t_classic << " ms/frame"); +} + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b10a228d..0dade2aa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -63,6 +63,7 @@ ADD_EXECUTABLE(jfjoch_test ResolutionShellsTest.cpp ImageSpotFinderCPUTest.cpp ImageSpotFinderGPUTest.cpp + AdaptiveSpotFinderGPUTest.cpp CalcBraggPredictionTest.cpp SpotUtilsTest.cpp LatticeSearchTest.cpp diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index 8c503241..88471c1d 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -368,7 +368,7 @@ void JFJochImageReadingWorker::UpdateAzint_i(const JFJochReaderDataset *dataset) // never scales the accumulated run, so don't retain the whole-run integration_outcome vector. index_and_refine = std::make_unique(curr_experiment, indexing.get(), /*retain_outcomes=*/false); image_analysis = std::make_unique(curr_experiment, *azint_mapping, *dataset->pixel_mask, - *index_and_refine.get()); + *index_and_refine.get(), /*enable_fused_adaptive_gpu=*/true); last_profile_.reset(); } diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index ce86c51a..82379ae0 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -224,10 +224,16 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { snr->setValue(spot_.signal_to_noise_threshold); auto *count = new SliderPlusBox(0.0, 100.0, 1.0, 0, page); count->setValue(std::lround(spot_.photon_count_threshold)); + auto *adaptive = new QCheckBox("Adaptive threshold (per-ring, auto)", page); + adaptive->setChecked(spot_.adaptive_threshold); + adaptive->setToolTip("Self-calibrating detection: the strong-pixel threshold is derived from each " + "image's own per-resolution-ring noise. The signal/noise and photon-count " + "settings are not used while this is on."); auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page); highResSpot->setValue(spot_.high_resolution_limit); auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(spot_.min_pix_per_spot), 0, "px", page); auto *maxSpots = new NumberLineEdit(10.0f, 100000.0f, static_cast(max_spots_), 0, "", page); + spot->addRow("", adaptive); spot->addRow("Signal/noise", snr); spot->addRow("Photon count", count); spot->addRow("High resolution [Å]", highResSpot); @@ -246,6 +252,18 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { spot_.min_pix_per_spot = std::llround(minPix->value()); EmitSpotFinding(); }); connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] { max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); }); + // The adaptive finder sets its own threshold from each image's noise, so the signal/noise and + // photon-count sliders do nothing while it is on - grey them out to make that clear. + auto syncAdaptiveEnabled = [snr, count](bool on) { + snr->setEnabled(!on); + count->setEnabled(!on); + }; + syncAdaptiveEnabled(spot_.adaptive_threshold); + connect(adaptive, &QCheckBox::toggled, this, [this, syncAdaptiveEnabled](bool on) { + spot_.adaptive_threshold = on; + syncAdaptiveEnabled(on); + EmitSpotFinding(); + }); // --- Indexing --- auto *idxSection = new CollapsibleSection("Indexing", page); -- 2.54.0 From a8c1006c4944111e6001761df51cafc070573399 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 18:31:35 +0200 Subject: [PATCH 016/295] Choose min-pix-per-spot adaptively per image for serial-stills indexing For stills indexing the minimum-pixels-per-spot filter is now chosen per image instead of being fixed: the frame is indexed at min-pix 3/2/1 and the setting that maximises indexed-spot count weighted by indexed fraction (n_indexed^2 / n_total) is kept, then integrated once at that min-pix. The fraction factor keeps a smaller min-pix's extra spots only when the lattice actually explains them, so strong frames retain their real weak spots (extending resolution) while noise-flooded frames stay strict. The mode is selected by the presence of --min-pix-per-spot, now optional (SpotFindingSettings::min_pix_per_spot is std::optional): omit it for the adaptive per-image path, give a value to force a fixed min-pix. It applies only to the stills indexing path -- rotation indexing builds one global lattice and keeps a fixed min-pix, and the online receiver and the FPGA host path always carry a concrete value, so neither changes. IndexAndRefine::ProcessImage now returns whether the frame indexed, to drive the per-image selection. Exposed in the jfjoch_viewer spot-finding settings (adaptive-threshold and adaptive-min-pix checkboxes, each greying out the control it overrides); the broker uses neither. Validated on the full rotation regression battery (no regression) and the whole serial-stills target battery at full image count. Co-Authored-By: Claude Opus 4.8 --- acquisition_device/AcquisitionDevice.cpp | 2 +- broker/OpenAPIConvert.cpp | 2 +- common/DiffractionExperiment.cpp | 4 +- docs/CPU_DATA_ANALYSIS.md | 8 +++ docs/RUGNUX.md | 15 +++-- image_analysis/IndexAndRefine.cpp | 3 +- image_analysis/IndexAndRefine.h | 4 +- image_analysis/MXAnalysisWithoutFPGA.cpp | 66 +++++++++++++++---- .../spot_finding/SpotFindingSettings.h | 6 +- .../spot_finding/StrongPixelSet.cpp | 4 +- rugnux/RugnuxCommandLine.cpp | 4 ++ rugnux/rugnux_cli.cpp | 8 ++- viewer/widgets/JFJochViewerSettingsDock.cpp | 20 +++++- 13 files changed, 115 insertions(+), 31 deletions(-) diff --git a/acquisition_device/AcquisitionDevice.cpp b/acquisition_device/AcquisitionDevice.cpp index 9c2e7160..ed410ab9 100644 --- a/acquisition_device/AcquisitionDevice.cpp +++ b/acquisition_device/AcquisitionDevice.cpp @@ -324,6 +324,6 @@ void AcquisitionDevice::SetSpotFinderParameters(const SpotFindingSettings &setti fpga_parameters.count_threshold = settings.photon_count_threshold; fpga_parameters.max_d = settings.low_resolution_limit; fpga_parameters.min_d = settings.high_resolution_limit; - fpga_parameters.min_pix_per_spot = settings.min_pix_per_spot; + fpga_parameters.min_pix_per_spot = settings.min_pix_per_spot.value_or(2); HW_SetSpotFinderParameters(fpga_parameters); } diff --git a/broker/OpenAPIConvert.cpp b/broker/OpenAPIConvert.cpp index 626fd716..4783dcb2 100644 --- a/broker/OpenAPIConvert.cpp +++ b/broker/OpenAPIConvert.cpp @@ -32,7 +32,7 @@ org::openapitools::server::model::Spot_finding_settings Convert(const SpotFindin org::openapitools::server::model::Spot_finding_settings ret; ret.setSignalToNoiseThreshold(input.signal_to_noise_threshold); ret.setPhotonCountThreshold(input.photon_count_threshold); - ret.setMinPixPerSpot(input.min_pix_per_spot); + ret.setMinPixPerSpot(input.min_pix_per_spot.value_or(2)); ret.setMaxPixPerSpot(input.max_pix_per_spot); ret.setHighResolutionLimit(input.high_resolution_limit); ret.setLowResolutionLimit(input.low_resolution_limit); diff --git a/common/DiffractionExperiment.cpp b/common/DiffractionExperiment.cpp index 81593874..38f50880 100644 --- a/common/DiffractionExperiment.cpp +++ b/common/DiffractionExperiment.cpp @@ -608,8 +608,8 @@ void DiffractionExperiment::CheckDataProcessingSettings(const SpotFindingSetting check_finite("Signal to noise threshold", settings.signal_to_noise_threshold); check_min("Signal to noise threshold", settings.signal_to_noise_threshold, 1); check_min("Photon count threshold", settings.photon_count_threshold, 0); - check_min("Minimum pixels per spot", settings.min_pix_per_spot, 1); - check_min("Maximum pixels per spot", settings.max_pix_per_spot, settings.min_pix_per_spot + 1); + check_min("Minimum pixels per spot", settings.min_pix_per_spot.value_or(2), 1); + check_min("Maximum pixels per spot", settings.max_pix_per_spot, settings.min_pix_per_spot.value_or(2) + 1); check_finite("Spot finding high resolution limit", settings.high_resolution_limit); check_finite("Spot finding low resolution limit", settings.low_resolution_limit); diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index d9aa7df5..c284ac71 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -219,6 +219,14 @@ Strong pixels are grouped into connected components (adjacent strong pixels) usi Spot-level filters include minimum/maximum pixel count and resolution limits. +### 3.5 Adaptive per-image minimum spot size + +The minimum-pixels-per-spot filter (§3.4) trades sensitivity against noise: a small value keeps faint one- or two-pixel spots — real signal on strong data, but detector noise on high-background frames — while a larger value keeps only well-formed spots. The best value is dataset-dependent, so for serial-stills indexing it can be chosen **per image** rather than fixed. The frame is indexed three times, at min-pix 3, 2 and 1, and the setting that maximises + +$$ \frac{n_\mathrm{indexed}^2}{n_\mathrm{total}} \quad\text{(indexed-spot count weighted by indexed fraction)} $$ + +is kept; the frame is then integrated once at that min-pix. The fraction factor discounts the extra spots a smaller min-pix admits *unless the lattice actually explains them*, so strong frames keep their real weak spots (extending resolution) while noise-flooded frames stay strict. Because min-pix filters the connected components *after* detection, the three attempts only repeat the cheap CCL and spot-level filter, not the pixel reduction; the azimuthal profile is identical across them. This is a **stills-only, indexing-path** option — rotation indexing builds one global lattice from all frames and keeps a fixed min-pix. In `rugnux` it is the default; giving an explicit `--min-pix-per-spot` pins a fixed value instead. + --- ## 4. Indexing overview diff --git a/docs/RUGNUX.md b/docs/RUGNUX.md index 953c5034..68075a24 100644 --- a/docs/RUGNUX.md +++ b/docs/RUGNUX.md @@ -150,15 +150,18 @@ then merge against a reference structure: rugnux serial_master.h5 \ -o serial_run -N 32 \ -X ffbidx -C 79,79,38,90,90,90 -S 96 \ - --spot-sigma 4 \ + --adaptive-spots \ -z reference.mtz \ --scaling-high-resolution 1.8 ``` `ffbidx` requires a known cell (`-C`) and is the indexer of choice for sparse serial stills. For -weak serial data, tightening spot finding with `--spot-sigma 4` typically raises the indexing rate -substantially. If a dataset *does* carry a goniometer axis but you want per-frame stills processing -anyway, add `--force-still`. +serial stills, prefer the self-calibrating spot finder (`--adaptive-spots`) and leave +`--min-pix-per-spot` **unset** so it is chosen per image — across the still-target battery this +combination raises the indexing rate and typically extends resolution over a fixed threshold and +fixed min-pix, at equal or better CC½. (You can still pin a fixed threshold with `--spot-sigma` / +`--spot-threshold` and a fixed min-pix with `--min-pix-per-spot`.) If a dataset *does* carry a +goniometer axis but you want per-frame stills processing anyway, add `--force-still`. ## Command-line options @@ -186,9 +189,11 @@ Spot finding: | --- | --- | | `--spot-sigma ` | Noise sigma level for spot finding (default: 3.0) | | `--spot-threshold ` | Photon-count threshold for spot finding (default: 10) | +| `--adaptive-spots` | Self-calibrating detection: replace the fixed `--spot-threshold` with a per-resolution-ring threshold derived from each image's own noise, so one setting adapts across datasets (no per-dataset `--spot-threshold`/`--spot-sigma` tuning) | +| `--spot-false-pixels ` | Adaptive-detection operating point: expected noise pixels tolerated per frame (default: 100; implies `--adaptive-spots`) | | `--spot-high-resolution ` | High-resolution limit for spot finding, Å (default: 1.5) | | `--spot-low-resolution ` | Low-resolution limit for spot finding, Å (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weak serial data) | -| `--min-pix-per-spot ` | Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 and a higher `--spot-threshold`) | +| `--min-pix-per-spot ` | Minimum connected strong pixels per spot. **If omitted, min-pix is chosen per image** (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed-spot count × indexed fraction is kept. Give an explicit value to force a fixed min-pix instead. | | `--max-spots ` | Maximum spot count (default: 250) | | `--detect-ice-rings[=on\|off]` | Flag ice-ring spots (de-prioritised in indexing) and exclude ice-ring reflections from scaling/merging; overrides the dataset/master-file setting (default: use the dataset value) | diff --git a/image_analysis/IndexAndRefine.cpp b/image_analysis/IndexAndRefine.cpp index 9b503036..08956bec 100644 --- a/image_analysis/IndexAndRefine.cpp +++ b/image_analysis/IndexAndRefine.cpp @@ -510,13 +510,14 @@ IndexAndRefine::DetermineRefineAnalyze(DataMessage &msg, const SpotFindingSettin return outcome; } -void IndexAndRefine::ProcessImage(DataMessage &msg, +bool IndexAndRefine::ProcessImage(DataMessage &msg, const SpotFindingSettings &spot_finding_settings, BraggPrediction &prediction, const BraggIntegrateFn &integrate) { auto outcome = DetermineRefineAnalyze(msg, spot_finding_settings); if (outcome && spot_finding_settings.quick_integration) QuickPredictAndIntegrate(msg, spot_finding_settings, prediction, integrate, *outcome); + return outcome.has_value(); } bool IndexAndRefine::IndexFrameOnly(DataMessage &msg, const SpotFindingSettings &spot_finding_settings) { diff --git a/image_analysis/IndexAndRefine.h b/image_analysis/IndexAndRefine.h index e870c6b2..dda9a0d3 100644 --- a/image_analysis/IndexAndRefine.h +++ b/image_analysis/IndexAndRefine.h @@ -102,7 +102,9 @@ public: prediction_mosaicity_override_ = std::move(mosaicity_per_frame); } - void ProcessImage(DataMessage &msg, const SpotFindingSettings &settings, + // Returns whether the frame indexed (a lattice was found and refined). Integration, when it runs, + // is a further step gated on quick_integration. + bool ProcessImage(DataMessage &msg, const SpotFindingSettings &settings, BraggPrediction &prediction, const BraggIntegrateFn &integrate); // Index a single frame (no integration) with the current forced rotation lattice; used to score // first-pass sampling schemes on the real per-image path. Returns whether the frame indexed. diff --git a/image_analysis/MXAnalysisWithoutFPGA.cpp b/image_analysis/MXAnalysisWithoutFPGA.cpp index bf021e89..7d188a38 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.cpp +++ b/image_analysis/MXAnalysisWithoutFPGA.cpp @@ -3,6 +3,8 @@ #include "MXAnalysisWithoutFPGA.h" +#include + #include "spot_finding/StrongPixelSet.h" #include "../compression/JFJochDecompress.h" @@ -117,30 +119,68 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, || mask_low_res != spot_finding_settings.low_resolution_limit) UpdateMaskResolution(spot_finding_settings); - const auto spot_finding_start_time = std::chrono::steady_clock::now(); ImageSpotFinder &finder = spot_finding_settings.adaptive_threshold ? static_cast(*adaptiveSpotFinder) : *spotFinder; - const std::vector spots = finder.Run(*preprocessor_buffer, spot_finding_settings, mask_resolution); - SpotAnalyze(experiment, spot_finding_settings, spots, output); - const auto spot_finding_end_time = std::chrono::steady_clock::now(); - output.spot_finding_time_s = std::chrono::duration(spot_finding_end_time - spot_finding_start_time).count(); + const auto integrate_fn = [this](const std::vector &predicted, size_t npredicted, + int64_t image_number) { + return bragg_engine->Run(*preprocessor_buffer, predicted, npredicted, image_number); + }; + + // A missing min-pix (std::nullopt) means "choose it per image". This applies only to the stills + // indexing path (each frame is indexed independently); rotation indexing builds one lattice from + // all frames, so it keeps the fixed min-pix and the single-pass finder. + const bool adaptive_min_pix = !spot_finding_settings.min_pix_per_spot.has_value() + && spot_finding_settings.indexing + && !experiment.IsRotationIndexing(); + if (adaptive_min_pix) { + // Choose the per-image min-pix adaptively instead of a fixed one. min-pix filters + // connected components AFTER detection, so re-running the finder only re-does the cheap CCL + + // spot filter, not the reduction; the azimuthal profile is identical across attempts. Index + // at 3/2/1 (index-only, no integration/accumulation) and keep whichever maximises + // n_indexed^2 / n_total (indexed count weighted by indexed fraction), then integrate once at + // that min-pix. spot_finding_time_s covers the whole escalation. + const auto start_time = std::chrono::steady_clock::now(); + SpotFindingSettings s = spot_finding_settings; + int best_mp = 0; + double best_score = -1.0; + for (int mp : {3, 2, 1}) { + s.min_pix_per_spot = mp; + const std::vector spots = finder.Run(*preprocessor_buffer, s, mask_resolution); + SpotAnalyze(experiment, s, spots, output); + if (indexer.IndexFrameOnly(output, s)) { + const double n_idx = static_cast(output.spot_count_indexed.value_or(0)); + const double n_tot = static_cast(std::max(1, output.spot_count.value_or(1))); + const double score = n_idx * n_idx / n_tot; + if (score > best_score) { best_score = score; best_mp = mp; } + } + } + if (best_mp != 0) { + // Re-run spot finding + index at the winning min-pix and integrate there. + s.min_pix_per_spot = best_mp; + const std::vector spots = finder.Run(*preprocessor_buffer, s, mask_resolution); + SpotAnalyze(experiment, s, spots, output); + indexer.ProcessImage(output, s, *prediction, integrate_fn); + } + output.spot_finding_time_s = std::chrono::duration(std::chrono::steady_clock::now() - start_time).count(); + } else { + const auto spot_finding_start_time = std::chrono::steady_clock::now(); + const std::vector spots = finder.Run(*preprocessor_buffer, spot_finding_settings, mask_resolution); + SpotAnalyze(experiment, spot_finding_settings, spots, output); + output.spot_finding_time_s = std::chrono::duration(std::chrono::steady_clock::now() - spot_finding_start_time).count(); + if (spot_finding_settings.indexing) + indexer.ProcessImage(output, spot_finding_settings, *prediction, integrate_fn); + } #ifdef JFJOCH_USE_CUDA if (fused) { - // Lift the azimuthal profile the fused engine computed in the same pass; its azint cost is - // folded into spot_finding_time_s above. + // Lift the azimuthal profile the fused engine computed in the same pass (identical across + // any min-pix retries); its azint cost is folded into spot_finding_time_s above. profile.Clear(integration); profile += fused_adaptive->GetProfile(); output.azint_time_s = 0.0f; } #endif - - if (spot_finding_settings.indexing) - indexer.ProcessImage(output, spot_finding_settings, *prediction, - [this](const std::vector &predicted, size_t npredicted, int64_t image_number) { - return bragg_engine->Run(*preprocessor_buffer, predicted, npredicted, image_number); - }); } output.max_viable_pixel_value = ret.max_value; diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index b69b7072..96a4c9af 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -10,7 +10,11 @@ struct SpotFindingSettings { bool enable = true; float signal_to_noise_threshold = 4.0; // STRONG_PIXEL in XDS int64_t photon_count_threshold = 10; // Threshold in photon counts - int64_t min_pix_per_spot = 2; // Minimum pixels per spot + // Minimum connected pixels per spot. std::nullopt = choose it per image: on the stills indexing + // path the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction + // is kept (see MXAnalysisWithoutFPGA::Analyze); a value fixes it. Defaults to a concrete value, so + // the online receiver and the FPGA path keep the single-pass fixed behaviour unless set otherwise. + std::optional min_pix_per_spot = 2; int64_t max_pix_per_spot = 50; // Maximum pixels per spot float high_resolution_limit = 2.0; float low_resolution_limit = 50.0; diff --git a/image_analysis/spot_finding/StrongPixelSet.cpp b/image_analysis/spot_finding/StrongPixelSet.cpp index 439d3050..3a6ac524 100644 --- a/image_analysis/spot_finding/StrongPixelSet.cpp +++ b/image_analysis/spot_finding/StrongPixelSet.cpp @@ -89,7 +89,7 @@ void StrongPixelSet::FindSpotsImage(const SpotFindingSettings &settings, std::ve if (!pixels.empty() && (strong_pixel_count < UINT16_MAX)) { for (const auto &spot: sparseccl()) { if ((spot.PixelCount() <= settings.max_pix_per_spot) - && (spot.PixelCount() >= settings.min_pix_per_spot)) { + && (spot.PixelCount() >= settings.min_pix_per_spot.value_or(2))) { spots.push_back(spot); } } @@ -102,7 +102,7 @@ void StrongPixelSet::FindSpots(const DiffractionExperiment &experiment, const Sp if (!pixels.empty() && (strong_pixel_count < UINT16_MAX)) { for (const auto &spot: sparseccl()) { if ((spot.PixelCount() <= settings.max_pix_per_spot) - && (spot.PixelCount() >= settings.min_pix_per_spot)) { + && (spot.PixelCount() >= settings.min_pix_per_spot.value_or(2))) { auto s = spot; s.ConvertToImageCoordinates(experiment, module_number); spots.push_back(s); diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index e2fdab15..751821fe 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -87,6 +87,10 @@ std::string RugnuxCommandLine(const ProcessConfig &config, add("--spot-threshold", std::to_string(sf.photon_count_threshold)); if (sf.adaptive_threshold) add("--spot-false-pixels", num(sf.false_pixels_per_frame)); + // min-pix is chosen per image unless an explicit value is given, so emit --min-pix-per-spot only + // when a fixed min-pix was selected; its absence selects the adaptive per-image path. + if (sf.min_pix_per_spot.has_value()) + add("--min-pix-per-spot", std::to_string(*sf.min_pix_per_spot)); add("--spot-high-resolution", num(sf.high_resolution_limit)); add("--max-spots", std::to_string(experiment.GetMaxSpotCount())); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 17eff6e5..15b579d7 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -64,7 +65,7 @@ void print_usage() { std::cout << " Spot finding" << std::endl; std::cout << " --spot-sigma Noise sigma level for spot finding (default: 3.0)" << std::endl; std::cout << " --spot-threshold Photon count threshold for spot finding (default: 10)" << std::endl; - std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot (default: 2; serial data can index better with 1 + a higher --spot-threshold)" << std::endl; + std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot. If omitted, min-pix is chosen PER IMAGE (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction is kept. Give an explicit value to force a fixed min-pix instead." << std::endl; std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; @@ -513,7 +514,7 @@ int main(int argc, char **argv) { std::optional max_spot_count_override; float sigma_spot_finding = 3.0; int64_t photon_count_threshold_spot_finding = 10; - int64_t min_pix_per_spot = 2; + std::optional min_pix_per_spot; // unset -> adaptive per image; a value -> fixed min-pix bool adaptive_spots = false; float false_pixels_per_frame = 100.0f; bool refine_bfactor = false; @@ -741,8 +742,9 @@ int main(int argc, char **argv) { photon_count_threshold_spot_finding); break; case OPT_MIN_PIX_PER_SPOT: + // Giving an explicit min-pix opts out of the per-image adaptive selection. min_pix_per_spot = parse_number_arg(optarg, "--min-pix-per-spot", logger, 1); - logger.Info("Minimum pixels per spot set to {:d}", min_pix_per_spot); + logger.Info("Minimum pixels per spot fixed at {:d} (adaptive per-image min-pix off)", *min_pix_per_spot); break; case OPT_ADAPTIVE_SPOTS: adaptive_spots = true; diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index 82379ae0..3203b55b 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -231,12 +231,18 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { "settings are not used while this is on."); auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page); highResSpot->setValue(spot_.high_resolution_limit); - auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(spot_.min_pix_per_spot), 0, "px", page); + auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(spot_.min_pix_per_spot.value_or(2)), 0, "px", page); + auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (per image)", page); + adaptiveMinPix->setChecked(!spot_.min_pix_per_spot.has_value()); + adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image (stills indexing): index at " + "min-pix 3/2/1 and keep whichever maximises indexed count x indexed " + "fraction. The fixed min-pixels/spot value is not used while this is on."); auto *maxSpots = new NumberLineEdit(10.0f, 100000.0f, static_cast(max_spots_), 0, "", page); spot->addRow("", adaptive); spot->addRow("Signal/noise", snr); spot->addRow("Photon count", count); spot->addRow("High resolution [Å]", highResSpot); + spot->addRow("", adaptiveMinPix); spot->addRow("Min pixels/spot", minPix); spot->addRow("Max spots/image", maxSpots); spotSection->setContentLayout(spot); @@ -264,6 +270,18 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { syncAdaptiveEnabled(on); EmitSpotFinding(); }); + // Adaptive min-pix chooses the value per image (min_pix_per_spot = std::nullopt), so the fixed + // min-pixels/spot field is unused while it is on - grey it out to make that clear. + auto syncMinPixEnabled = [minPix](bool adaptive_on) { minPix->setEnabled(!adaptive_on); }; + syncMinPixEnabled(!spot_.min_pix_per_spot.has_value()); + connect(adaptiveMinPix, &QCheckBox::toggled, this, [this, minPix, syncMinPixEnabled](bool on) { + if (on) + spot_.min_pix_per_spot = std::nullopt; + else + spot_.min_pix_per_spot = std::llround(minPix->value()); + syncMinPixEnabled(on); + EmitSpotFinding(); + }); // --- Indexing --- auto *idxSection = new CollapsibleSection("Indexing", page); -- 2.54.0 From 96e10fd1f01f7aa2aebdb3b7921810bce0114a39 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 18:55:22 +0200 Subject: [PATCH 017/295] Viewer: reuse the QImage buffer in GeneratePixmap The qimg_buffer_ member was added to avoid reallocating the full-size image every recolour, but GeneratePixmap still built a local QImage and the member was never referenced. Wire it up: the buffer is reallocated only when the image dimensions change. The data pointer is taken once, before the parallel loop. scanLine() is non-const and would otherwise have every worker detach the buffer at the same time, which is a data race as soon as the buffer is shared with the pixmap. 18.1 Mpx recolour: 28.0 -> 22 ms (measured on the colouring path alone). Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochImage.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 50ab8cd8..6ae8bf11 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -651,7 +651,13 @@ void JFJochImage::Redraw() { } void JFJochImage::GeneratePixmap() { - QImage qimg(int(W), int(H), QImage::Format_RGB32); + if (qimg_buffer_.width() != int(W) || qimg_buffer_.height() != int(H)) + qimg_buffer_ = QImage(int(W), int(H), QImage::Format_RGB32); + + // Take the data pointer once, here: scanLine() is non-const, so calling it from the + // workers below would have each of them detach the (possibly shared) buffer in parallel. + uchar *const bits = qimg_buffer_.bits(); + const qsizetype stride = qimg_buffer_.bytesPerLine(); image_rgb.resize(W * H); @@ -685,7 +691,7 @@ void JFJochImage::GeneratePixmap() { for (int y = 0; y < H; ++y) rows.push_back(y); QtConcurrent::blockingMap(rows, [&](int y) { - QRgb *scanLine = reinterpret_cast(qimg.scanLine(y)); + QRgb *scanLine = reinterpret_cast(bits + y * stride); const float *row = &image_fp[y * W]; rgb *out = &image_rgb[y * W]; @@ -726,7 +732,7 @@ void JFJochImage::GeneratePixmap() { } }); - pixmap = QPixmap::fromImage(qimg); + pixmap = QPixmap::fromImage(qimg_buffer_); pixmap.setDevicePixelRatio(1.0); } -- 2.54.0 From 0cee55f654e86fe775c5aa93ce692a259a379290 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 18:56:17 +0200 Subject: [PATCH 018/295] Viewer: drop the full-size image_rgb mirror GeneratePixmap wrote every pixel twice: once into the QImage and once into image_rgb. The only reader was writePixelLabels, which needs a colour for at most 5000 pixels and only above 30x zoom, so the mirror cost a W*H*3 buffer and a second store per pixel to serve a fraction of a percent of them. Read the colour back from the rendered image instead. 18.1 Mpx colouring loop: 9.5 -> 6.2 ms. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochImage.cpp | 11 ++++++----- viewer/image_viewer/JFJochImage.h | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 6ae8bf11..a75b5840 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -659,8 +659,6 @@ void JFJochImage::GeneratePixmap() { uchar *const bits = qimg_buffer_.bits(); const qsizetype stride = qimg_buffer_.bytesPerLine(); - image_rgb.resize(W * H); - // Bad pixel color int r, g, b, a; feature_color.getRgb(&r, &g, &b, &a); @@ -693,7 +691,6 @@ void JFJochImage::GeneratePixmap() { QtConcurrent::blockingMap(rows, [&](int y) { QRgb *scanLine = reinterpret_cast(bits + y * stride); const float *row = &image_fp[y * W]; - rgb *out = &image_rgb[y * W]; for (int x = 0; x < W; ++x) { const float fp = row[x]; @@ -727,7 +724,6 @@ void JFJochImage::GeneratePixmap() { c = lut_data[idx]; } - out[x] = c; scanLine[x] = qRgb(c.r, c.g, c.b); } }); @@ -821,7 +817,12 @@ void JFJochImage::writePixelLabels() { auto *textItem = new QGraphicsSimpleTextItem(*pText); textItem->setFont(font); - if (luminance(image_rgb[idx]) > 128.0) + // Read the colour back from the rendered image rather than keeping a + // full-size mirror of it around for the few pixels that get a label. + const QRgb pxl = qimg_buffer_.pixel(x, y); + if (luminance(rgb{.r = static_cast(qRed(pxl)), + .g = static_cast(qGreen(pxl)), + .b = static_cast(qBlue(pxl))}) > 128.0) textItem->setBrush(Qt::black); else textItem->setBrush(Qt::white); diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index a6955915..2d3199d3 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -62,7 +62,6 @@ protected: float foreground = 10.0; float background = 0.0; ColorScale color_scale; - std::vector image_rgb; std::vector image_fp; QPixmap pixmap; QImage qimg_buffer_; // reusable image buffer — avoids 64MB alloc per frame -- 2.54.0 From a704a2cd339019c6f4b4a9c247954fd3e80e3b83 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 18:58:55 +0200 Subject: [PATCH 019/295] Viewer: draw the rendered image directly instead of via a QPixmap Every recolour ended with QPixmap::fromImage(), which allocates a second full-size buffer and converts the whole image into the screen format. That conversion was the largest single cost left in the colouring path. Replace QGraphicsPixmapItem with a small item that paints qimg_buffer_ with QPainter::drawImage. The buffer is already what the raster engine wants, so nothing is converted or copied. The item declares its opaque area, as the pixmap item did, so the view still skips the background fill underneath it, and it turns SmoothPixmapTransform off before drawing to keep the nearest-neighbour sampling QGraphicsPixmapItem gave us by default -- zoomed-in detector pixels stay sharp squares. GeneratePixmap is renamed RenderImage: it no longer makes a pixmap. 18.1 Mpx recolour: 22 -> 5.6 ms (28.0 ms before this series). Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochAzIntImage.cpp | 4 +- .../image_viewer/JFJochDiffractionImage.cpp | 8 +-- viewer/image_viewer/JFJochGridScanImage.cpp | 2 +- viewer/image_viewer/JFJochImage.cpp | 64 +++++++++++++------ viewer/image_viewer/JFJochImage.h | 25 ++++++-- viewer/image_viewer/JFJochSimpleImage.cpp | 2 +- 6 files changed, 71 insertions(+), 34 deletions(-) diff --git a/viewer/image_viewer/JFJochAzIntImage.cpp b/viewer/image_viewer/JFJochAzIntImage.cpp index 4588de53..449ee039 100644 --- a/viewer/image_viewer/JFJochAzIntImage.cpp +++ b/viewer/image_viewer/JFJochAzIntImage.cpp @@ -68,8 +68,8 @@ void JFJochAzIntImage::imageLoaded(std::shared_ptr in_i emit backgroundChanged(background); emit foregroundChanged(foreground); - // Generate pixmap and redraw using base class functionality - GeneratePixmap(); + // Render the image and redraw using base class functionality + RenderImage(); Redraw(); CalcROI(); } else { diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 536b24c2..9c535ad5 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -847,7 +847,7 @@ void JFJochDiffractionImage::UpdateForeground() { void JFJochDiffractionImage::setHDRMode(bool input) { hdr_mode = input; UpdateForeground(); - GeneratePixmap(); + RenderImage(); Redraw(); } @@ -857,7 +857,7 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr image = in_image; UpdateForeground(); LoadImageInternal(); - GeneratePixmap(); + RenderImage(); Redraw(); CalcROI(); } else { @@ -877,7 +877,7 @@ void JFJochDiffractionImage::setAutoForeground(bool input) { auto_fg = input; // If auto_foreground is not set, then view stays with the current settings till these are explicitly changed UpdateForeground(); - GeneratePixmap(); + RenderImage(); Redraw(); emit autoForegroundChanged(auto_fg); } @@ -937,7 +937,7 @@ void JFJochDiffractionImage::DrawCross(float x, float y, float size, float width void JFJochDiffractionImage::showSaturation(bool input) { show_saturation = input; - GeneratePixmap(); + RenderImage(); updateOverlay(); } diff --git a/viewer/image_viewer/JFJochGridScanImage.cpp b/viewer/image_viewer/JFJochGridScanImage.cpp index a20b0ca8..a98121e6 100644 --- a/viewer/image_viewer/JFJochGridScanImage.cpp +++ b/viewer/image_viewer/JFJochGridScanImage.cpp @@ -58,7 +58,7 @@ void JFJochGridScanImage::loadData(const std::vector &data, const GridSca background = minv; foreground = maxv; - GeneratePixmap(); + RenderImage(); Redraw(); CalcROI(); } diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index a75b5840..62ef5b1c 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -3,7 +3,6 @@ #include "JFJochImage.h" -#include #include #include #include @@ -20,6 +19,31 @@ #include #include +QRectF JFJochImageItem::boundingRect() const { + return QRectF(0, 0, img_.width(), img_.height()); +} + +QPainterPath JFJochImageItem::opaqueArea() const { + // The buffer is RGB32, so the item fully covers its bounding rect + QPainterPath path; + path.addRect(boundingRect()); + return path; +} + +void JFJochImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { + if (img_.isNull()) + return; + // QGraphicsPixmapItem defaults to Qt::FastTransformation and turned this hint off before + // drawing; keep doing that, so zoomed-in detector pixels stay sharp squares. + painter->setRenderHint(QPainter::SmoothPixmapTransform, false); + painter->drawImage(0, 0, img_); +} + +void JFJochImageItem::refresh() { + prepareGeometryChange(); + update(); +} + JFJochImage::JFJochImage(QWidget *parent) : QGraphicsView(parent) { setDragMode(QGraphicsView::NoDrag); // Disable default drag mode setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // Zoom anchors @@ -45,7 +69,7 @@ void JFJochImage::onScroll(int value) { void JFJochImage::changeBackground(float val) { background = val; - GeneratePixmap(); + RenderImage(); Redraw(); } @@ -54,7 +78,7 @@ void JFJochImage::changeForeground(float val) { emit autoForegroundChanged(false); foreground = val; // Regenerate the image - GeneratePixmap(); + RenderImage(); Redraw(); } @@ -62,7 +86,7 @@ void JFJochImage::setColorMap(int color_map) { try { color_scale.Select(static_cast(color_map)); // Regenerate the image - GeneratePixmap(); + RenderImage(); Redraw(); } catch (...) { } @@ -70,7 +94,7 @@ void JFJochImage::setColorMap(int color_map) { void JFJochImage::setFeatureColor(QColor input) { feature_color = input; - GeneratePixmap(); + RenderImage(); Redraw(); } @@ -333,7 +357,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) { QAction *fitAct = menu.addAction(tr("Fit image to view")); QAction *clearRoiAct = menu.addAction(tr("Clear ROI")); - const bool hasImage = (W > 0 && H > 0 && !pixmap.isNull()); + const bool hasImage = (W > 0 && H > 0 && !qimg_buffer_.isNull()); copyImageAct->setEnabled(hasImage); copyWithOverlayAct->setEnabled(hasImage && scene()); saveImageAct->setEnabled(hasImage); @@ -393,7 +417,7 @@ QImage JFJochImage::renderToImage(bool with_overlay) { p.end(); } else { // The underlying rendered image (no overlay) - img = pixmap.toImage(); + img = qimg_buffer_; } // Ensure 1:1 pixel ratio and 96 DPI metadata to avoid rescaling in consumer apps img.setDevicePixelRatio(1.0); @@ -404,7 +428,7 @@ QImage JFJochImage::renderToImage(bool with_overlay) { } void JFJochImage::copyImageToClipboard() { - if (W == 0 || H == 0 || pixmap.isNull()) return; + if (W == 0 || H == 0 || qimg_buffer_.isNull()) return; setClipboardAsJpegAndImage(renderToImage(false), 95); emit writeStatusBar(tr("Image copied to clipboard"), 2000); @@ -418,7 +442,7 @@ void JFJochImage::copyImageWithOverlayToClipboard() { } void JFJochImage::saveImageToFile(bool with_overlay) { - if (W == 0 || H == 0 || pixmap.isNull()) return; + if (W == 0 || H == 0 || qimg_buffer_.isNull()) return; if (with_overlay && !scene()) return; const QString caption = with_overlay ? tr("Save image with overlay as JPEG") @@ -641,7 +665,7 @@ void JFJochImage::Redraw() { setScene(currentScene); // Reset initial-fit state for a new scene initial_fit_done_ = false; - pixmap_item_ = nullptr; // new scene, old pointer invalid + image_item_ = nullptr; // new scene, old pointer invalid } // Perform initial fit only once per image size @@ -650,7 +674,7 @@ void JFJochImage::Redraw() { updateOverlay(); } -void JFJochImage::GeneratePixmap() { +void JFJochImage::RenderImage() { if (qimg_buffer_.width() != int(W) || qimg_buffer_.height() != int(H)) qimg_buffer_ = QImage(int(W), int(H), QImage::Format_RGB32); @@ -727,9 +751,6 @@ void JFJochImage::GeneratePixmap() { scanLine[x] = qRgb(c.r, c.g, c.b); } }); - - pixmap = QPixmap::fromImage(qimg_buffer_); - pixmap.setDevicePixelRatio(1.0); } void JFJochImage::centerOnSpot(QPointF point) { @@ -837,7 +858,7 @@ void JFJochImage::writePixelLabels() { } void JFJochImage::resetScenePointers() { - pixmap_item_ = nullptr; + image_item_ = nullptr; overlay_items_.clear(); } @@ -846,18 +867,19 @@ void JFJochImage::updateOverlay() { beforeOverlayCleared(); - // Remove only overlay items, keep the pixmap item persistent + // Remove only overlay items, keep the image item persistent for (auto *item : overlay_items_) scene()->removeItem(item); qDeleteAll(overlay_items_); overlay_items_.clear(); - // Ensure pixmap item exists and is up-to-date - if (!pixmap_item_) { - pixmap_item_ = scene()->addPixmap(pixmap); - pixmap_item_->setZValue(0); + // Ensure the image item exists and is up-to-date + if (!image_item_) { + image_item_ = new JFJochImageItem(qimg_buffer_); + image_item_->setZValue(0); + scene()->addItem(image_item_); } else { - pixmap_item_->setPixmap(pixmap); + image_item_->refresh(); } if (scale_factor > 30.0) diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index 2d3199d3..a6f9e3f2 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -4,7 +4,10 @@ #pragma once #include +#include +#include #include +#include #include #include #include "../../common/ColorScale.h" @@ -12,6 +15,19 @@ // Q_DECLARE_METATYPE(ROIMessage) +// Draws the rendered frame straight out of JFJochImage::qimg_buffer_. A QGraphicsPixmapItem +// would mean converting the whole image into a QPixmap on every recolour, which costs one +// extra allocation and a full pass over the pixels. +class JFJochImageItem : public QGraphicsItem { + const QImage &img_; +public: + explicit JFJochImageItem(const QImage &img) : img_(img) {} + QRectF boundingRect() const override; + QPainterPath opaqueArea() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + void refresh(); // the buffer behind the item changed +}; + class JFJochImage : public QGraphicsView { Q_OBJECT @@ -63,11 +79,10 @@ protected: float background = 0.0; ColorScale color_scale; std::vector image_fp; - QPixmap pixmap; QImage qimg_buffer_; // reusable image buffer — avoids 64MB alloc per frame - // Persistent pixmap item — never destroyed/recreated on overlay update - QGraphicsPixmapItem *pixmap_item_ = nullptr; + // Persistent image item — never destroyed/recreated on overlay update + JFJochImageItem *image_item_ = nullptr; // Overlay items managed separately QList overlay_items_; @@ -103,11 +118,11 @@ protected: ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const; void updateOverlay(); - void GeneratePixmap(); + void RenderImage(); void Redraw(); void CalcROI(); - // Invalidate pixmap_item_ and overlay tracking after scene()->clear() + // Invalidate image_item_ and overlay tracking after scene()->clear() void resetScenePointers(); // Perform initial fit-to-view (shorter direction), once per image size diff --git a/viewer/image_viewer/JFJochSimpleImage.cpp b/viewer/image_viewer/JFJochSimpleImage.cpp index bfa029e7..25156a6e 100644 --- a/viewer/image_viewer/JFJochSimpleImage.cpp +++ b/viewer/image_viewer/JFJochSimpleImage.cpp @@ -27,7 +27,7 @@ void JFJochSimpleImage::setImage(std::shared_ptr img) { if (img) { image_ = std::move(img); loadImageInternal(); - GeneratePixmap(); + RenderImage(); Redraw(); CalcROI(); } else { -- 2.54.0 From 417170bc13df417c32de9a1b7b245d82d2bc9c79 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 18:59:45 +0200 Subject: [PATCH 020/295] Viewer: coalesce foreground/background recolours Ctrl+wheel, Shift+wheel and the foreground slider each recoloured the whole image synchronously, once per input event. On a large detector the recolour is slower than the events arrive, so they queued up and the view lagged behind the cursor for as long as the user kept scrolling. Defer the recolour to a zero-delay single shot and drop the intermediate values: at most one recolour is in flight, and it always uses the newest foreground/background. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochImage.cpp | 17 +++++++++++++---- viewer/image_viewer/JFJochImage.h | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 62ef5b1c..f1452e44 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -67,10 +67,20 @@ void JFJochImage::onScroll(int value) { updateOverlay(); } +void JFJochImage::ScheduleRenderImage() { + if (render_pending_) + return; + render_pending_ = true; + QTimer::singleShot(0, this, [this] { + render_pending_ = false; + RenderImage(); + Redraw(); + }); +} + void JFJochImage::changeBackground(float val) { background = val; - RenderImage(); - Redraw(); + ScheduleRenderImage(); } void JFJochImage::changeForeground(float val) { @@ -78,8 +88,7 @@ void JFJochImage::changeForeground(float val) { emit autoForegroundChanged(false); foreground = val; // Regenerate the image - RenderImage(); - Redraw(); + ScheduleRenderImage(); } void JFJochImage::setColorMap(int color_map) { diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index a6f9e3f2..623f8125 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -119,6 +119,11 @@ protected: void updateOverlay(); void RenderImage(); + // Re-render once the event queue drains. The foreground slider and the wheel emit far + // faster than a large image can be recoloured, so intermediate values are dropped + // instead of queueing a full recolour per event. + void ScheduleRenderImage(); + bool render_pending_ = false; void Redraw(); void CalcROI(); -- 2.54.0 From fd9f93e1f1665420d4edb7d779db3401edf143e1 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 19:00:37 +0200 Subject: [PATCH 021/295] Viewer: stop copying the image in LoadImageInternal, parallelise it "auto img = image->Image()" deduced std::vector by value, so every frame copied the whole detector image before converting it -- 72 MB on a 16 Mpx detector. Bind a const reference instead. The sentinel-to-float conversion also ran single-threaded on the GUI thread; spread it over rows the same way RenderImage does. 18.1 Mpx: 11.8 -> ~1 ms, plus the copy that is now gone. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 9c535ad5..513b2519 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include "JFJochSimpleImage.h" @@ -129,19 +130,26 @@ void JFJochDiffractionImage::LoadImageInternal() { image_fp.resize(W*H); - auto img = image->Image(); - // Fill the QImage with pixel data from the array - for (int pxl = 0; pxl < W * H; pxl++) { - auto val = img[pxl]; - if (val == GAP_PXL_VALUE) - image_fp[pxl] = NAN; - else if (val == ERROR_PXL_VALUE) - image_fp[pxl] = -INFINITY; - else if (val == SATURATED_PXL_VALUE) - image_fp[pxl] = INFINITY; - else - image_fp[pxl] = static_cast(val); - } + const auto &img = image->Image(); + + QVector rows; + rows.reserve(H); + for (int y = 0; y < H; ++y) rows.push_back(y); + + // Fill the float image with pixel data from the array + QtConcurrent::blockingMap(rows, [&](int y) { + for (size_t pxl = y * W; pxl < (y + 1) * W; pxl++) { + auto val = img[pxl]; + if (val == GAP_PXL_VALUE) + image_fp[pxl] = NAN; + else if (val == ERROR_PXL_VALUE) + image_fp[pxl] = -INFINITY; + else if (val == SATURATED_PXL_VALUE) + image_fp[pxl] = INFINITY; + else + image_fp[pxl] = static_cast(val); + } + }); } void JFJochDiffractionImage::DrawSpots() { -- 2.54.0 From c3eba650e9d88ac357122b2b766fd6d63be1593f Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 19:03:05 +0200 Subject: [PATCH 022/295] Viewer: one overlay rebuild per pan/zoom, and only invalidate the image when it changed Panning called updateOverlay() three times per mouse move: once for each scrollbar's valueChanged -> onScroll(), then once explicitly. Zooming was the same. Every one of those tore down and rebuilt every overlay item. Suppress onScroll() for the duration of the gesture instead, and let the gesture do its single rebuild at the end. Note this cannot be done by blocking the scrollbars' signals: QAbstractScrollArea drives the actual scrolling off valueChanged, so blocking it would stop the view moving at all. updateOverlay() also refreshed the image item unconditionally, which marks the whole item dirty and forces a full-viewport repaint even though pan and zoom never change the pixels. Track whether RenderImage has run since the last refresh and skip it otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochImage.cpp | 25 +++++++++++++++++++++++-- viewer/image_viewer/JFJochImage.h | 4 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index f1452e44..1f7a9479 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -64,6 +64,8 @@ JFJochImage::JFJochImage(QWidget *parent) : QGraphicsView(parent) { } void JFJochImage::onScroll(int value) { + if (suppress_overlay_update_) + return; updateOverlay(); } @@ -134,6 +136,10 @@ void JFJochImage::wheelEvent(QWheelEvent *event) { changeForeground(new_foreground); emit foregroundChanged(foreground); } else { + // Zooming and re-centering both move the scrollbars, and every move reaches + // onScroll(); suppress those and rebuild the overlay once, below. + suppress_overlay_update_ = true; + // Perform zooming if (event->angleDelta().y() > 0) { if (scale_factor * zoomFactor < 500.0) { @@ -152,6 +158,8 @@ void JFJochImage::wheelEvent(QWheelEvent *event) { QPointF delta = targetScenePos - updatedViewportCenter; translate(delta.x(), delta.y()); // Shift the view + suppress_overlay_update_ = false; + updateOverlay(); emitViewportChanged(); } @@ -248,8 +256,12 @@ void JFJochImage::mouseMoveEvent(QMouseEvent *event) { const QPoint viewDelta = event->pos() - lastMousePos; lastMousePos = event->pos(); + // Each setValue() reaches onScroll(), so the overlay was rebuilt three times per + // mouse move. Suppress those and rebuild once, below. + suppress_overlay_update_ = true; horizontalScrollBar()->setValue(horizontalScrollBar()->value() - viewDelta.x()); verticalScrollBar()->setValue(verticalScrollBar()->value() - viewDelta.y()); + suppress_overlay_update_ = false; updateOverlay(); emitViewportChanged(); @@ -760,6 +772,8 @@ void JFJochImage::RenderImage() { scanLine[x] = qRgb(c.r, c.g, c.b); } }); + + image_dirty_ = true; } void JFJochImage::centerOnSpot(QPointF point) { @@ -779,9 +793,12 @@ void JFJochImage::applyViewport(QTransform transform, QPointF center) { if (m_applyingViewport || !scene()) return; m_applyingViewport = true; + // As in wheelEvent: one rebuild, not one per scrollbar move + suppress_overlay_update_ = true; setTransform(transform); scale_factor = transform.m11(); centerOn(center); + suppress_overlay_update_ = false; updateOverlay(); m_applyingViewport = false; } @@ -882,13 +899,17 @@ void JFJochImage::updateOverlay() { qDeleteAll(overlay_items_); overlay_items_.clear(); - // Ensure the image item exists and is up-to-date + // Ensure the image item exists and is up-to-date. Refreshing it marks the whole item + // dirty, which forces a full repaint of the viewport, so only do it when the image + // really changed - not on every pan and zoom. if (!image_item_) { image_item_ = new JFJochImageItem(qimg_buffer_); image_item_->setZValue(0); scene()->addItem(image_item_); - } else { + image_dirty_ = false; + } else if (image_dirty_) { image_item_->refresh(); + image_dirty_ = false; } if (scale_factor > 30.0) diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index 623f8125..7bf2f6e3 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -124,6 +124,10 @@ protected: // instead of queueing a full recolour per event. void ScheduleRenderImage(); bool render_pending_ = false; + bool image_dirty_ = false; // qimg_buffer_ changed since the item was last refreshed + // Set while a pan/zoom moves the scrollbars, so onScroll() does not rebuild the overlay + // once per scrollbar; the gesture rebuilds it once itself. + bool suppress_overlay_update_ = false; void Redraw(); void CalcROI(); -- 2.54.0 From 0b926af5af7517bfafc0d4fe77841f8cffc33767 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 19:04:52 +0200 Subject: [PATCH 023/295] Viewer: cache the traced resolution-ring contours DrawResolutionRings traced every ring point by point on each overlay rebuild: 361 ResPhiToPxl calls per ring, so about 4000 geometry evaluations per rebuild with the 11 ice rings shown -- and a rebuild happens on every pan step. The contours depend only on the ring list and the geometry, neither of which changes while the view moves, so keep them. The cache is keyed on the ring list (which RingMode::Auto recomputes from the visible area, so it still re-traces when it should) and cleared in loadImage for a possibly-new geometry. Labels are still placed per rebuild: they depend on the visible rect. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 76 +++++++++++-------- viewer/image_viewer/JFJochDiffractionImage.h | 6 ++ 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 513b2519..8811985b 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -301,43 +301,56 @@ void JFJochDiffractionImage::DrawResolutionRings() { float phi_offset = 0; - float res1 = geom.PxlToRes(0,0); - float res2 = geom.PxlToRes(image->Dataset().experiment.GetXPixelsNum(),0); - float res3 = geom.PxlToRes(image->Dataset().experiment.GetXPixelsNum(),image->Dataset().experiment.GetYPixelsNum()); - float res4 = geom.PxlToRes(0,image->Dataset().experiment.GetYPixelsNum()); + // Tracing the contours costs 361 geometry evaluations per ring, and they only move when the + // ring list or the geometry changes - not when the view is panned or zoomed, which is when + // most overlay rebuilds happen. Keep them until the ring list changes; loadImage() clears + // the cache so a new geometry re-traces. + if (ring_cache_key_ != res_ring) { + ring_cache_key_ = res_ring; + ring_cache_.clear(); - float min_res = std::min({res1, res2, res3, res4}); + float res1 = geom.PxlToRes(0,0); + float res2 = geom.PxlToRes(image->Dataset().experiment.GetXPixelsNum(),0); + float res3 = geom.PxlToRes(image->Dataset().experiment.GetXPixelsNum(),image->Dataset().experiment.GetYPixelsNum()); + float res4 = geom.PxlToRes(0,image->Dataset().experiment.GetYPixelsNum()); - for (const auto &d: res_ring) { - if (d < min_res) - continue; + float min_res = std::min({res1, res2, res3, res4}); - // Trace the constant-d contour through the geometry - a circle on an untilted detector, - // a conic on a tilted one - the same way an azimuthal ROI arc is drawn, instead of - // approximating it with an axis-aligned bounding-box ellipse. ResPhiToPxl throws when d is - // too high for the wavelength, and returns NaN where the contour leaves the detector plane. - QPainterPath path; - bool started = false; - bool valid = true; - constexpr int steps = 360; - for (int i = 0; i <= steps; i++) { - const float phi = 2.0f * static_cast(PI) * static_cast(i) / static_cast(steps); - try { - auto [x, y] = geom.ResPhiToPxl(d, phi); - if (!std::isfinite(x) || !std::isfinite(y)) { - started = false; // break the subpath where the ring leaves the detector - continue; + for (const auto &d: res_ring) { + if (d < min_res) + continue; + + // Trace the constant-d contour through the geometry - a circle on an untilted detector, + // a conic on a tilted one - the same way an azimuthal ROI arc is drawn, instead of + // approximating it with an axis-aligned bounding-box ellipse. ResPhiToPxl throws when d is + // too high for the wavelength, and returns NaN where the contour leaves the detector plane. + QPainterPath path; + bool started = false; + bool valid = true; + constexpr int steps = 360; + for (int i = 0; i <= steps; i++) { + const float phi = 2.0f * static_cast(PI) * static_cast(i) / static_cast(steps); + try { + auto [x, y] = geom.ResPhiToPxl(d, phi); + if (!std::isfinite(x) || !std::isfinite(y)) { + started = false; // break the subpath where the ring leaves the detector + continue; + } + if (!started) { path.moveTo(x, y); started = true; } + else path.lineTo(x, y); + } catch (...) { + valid = false; + break; } - if (!started) { path.moveTo(x, y); started = true; } - else path.lineTo(x, y); - } catch (...) { - valid = false; - break; } - } - if (!valid || path.isEmpty()) - continue; + if (!valid || path.isEmpty()) + continue; + ring_cache_.push_back({d, path}); + } + } + + for (const auto &[d, path]: ring_cache_) { addOverlayItem(scene()->addPath(path, pen)); // Place the "d Å" label at the first cardinal azimuth (staggered per ring) that is visible. @@ -861,6 +874,7 @@ void JFJochDiffractionImage::setHDRMode(bool input) { void JFJochDiffractionImage::loadImage(std::shared_ptr in_image) { live_pending_ = false; // a live ROI edit (if any) has now been recomputed + ring_cache_key_.clear(); // geometry may differ, re-trace the resolution rings if (in_image) { image = in_image; UpdateForeground(); diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index a8c2aac7..8c2f3c3b 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -3,6 +3,8 @@ #pragma once +#include + #include "JFJochImage.h" #include "../../reader/JFJochReaderImage.h" #include "../../common/ROIDefinition.h" @@ -79,6 +81,10 @@ private: QVector res_ring = {}; + // Constant-d contours traced for res_ring, kept across overlay rebuilds; see DrawResolutionRings + QVector ring_cache_key_ = {}; + QVector> ring_cache_ = {}; + RingMode ring_mode = RingMode::Estimation; bool show_spots = false; -- 2.54.0 From 3eccc58961d207b312214cc8d4fd9b8eb7f3198a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 19:12:55 +0200 Subject: [PATCH 024/295] Viewer: colour the diffraction image straight from int32 image_fp is the base class's one pixel representation, and it earns that for three of the four image widgets: the azimuthal image is already float, the grid scan holds computed 1/sigma^2 floats, and the calibration viewer accepts eight source types from uint8 to float64. The diffraction image is the odd one out -- its source is a large int32 buffer -- and it is the one paying: a full int32 -> float pass plus a second resident copy of the image, on every frame. Split the mapping from the source. PixelColorMap holds the precomputed LUT constants and does value -> colour; a virtual ColorRow() picks the pixels out of whatever buffer the subclass has. Both paths now go through the same Apply(), so only the gap/bad/saturated dispatch differs, and it lines up exactly with the encoding LoadImageInternal used: GAP_PXL_VALUE -> NAN -> gap ERROR_PXL_VALUE -> -INF -> bad SATURATED_PXL_VALUE -> +INF -> saturated The base class still needs real pixel values for ROI statistics and per-pixel labels, so image_fp is filled on demand instead of per frame -- and only when something reads it: a non-empty scratch ROI, or labels above 30x zoom. Neither happens while simply looking at frames, and nothing else routinely sets roiBox (the named ROIs are computed in the reading worker, not here). 18.1 Mpx: 10.5 -> 5.9 ms per frame and 72 MB less resident. 4.5 Mpx: 1.8 -> 1.0 ms and 18 MB. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 30 ++++- viewer/image_viewer/JFJochDiffractionImage.h | 5 + viewer/image_viewer/JFJochImage.cpp | 111 ++++++++---------- viewer/image_viewer/JFJochImage.h | 46 ++++++++ 4 files changed, 130 insertions(+), 62 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 8811985b..130aa865 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -128,9 +128,35 @@ void JFJochDiffractionImage::LoadImageInternal() { W = image->Dataset().experiment.GetXPixelsNum(); H = image->Dataset().experiment.GetYPixelsNum(); - image_fp.resize(W*H); + pixel_values_valid_ = false; // image_fp is filled on demand, see EnsurePixelValues +} + +void JFJochDiffractionImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const { + const int32_t *row = &image->Image()[y * W]; + + for (size_t x = 0; x < W; ++x) { + const int32_t v = row[x]; + + // The three sentinels are the extremes of the int32 range, so one range test + // separates them from every real pixel value + rgb c; + if (v > GAP_PXL_VALUE && v < SATURATED_PXL_VALUE) + c = map.Apply(static_cast(v)); + else if (v == GAP_PXL_VALUE) + c = map.gap; + else + c = (v == ERROR_PXL_VALUE) ? map.bad : map.saturated; + + out[x] = qRgb(c.r, c.g, c.b); + } +} + +void JFJochDiffractionImage::EnsurePixelValues() { + if (pixel_values_valid_ || !image) + return; const auto &img = image->Image(); + image_fp.resize(W*H); QVector rows; rows.reserve(H); @@ -150,6 +176,8 @@ void JFJochDiffractionImage::LoadImageInternal() { image_fp[pxl] = static_cast(val); } }); + + pixel_values_valid_ = true; } void JFJochDiffractionImage::DrawSpots() { diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 8c2f3c3b..e11e84ae 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -31,6 +31,11 @@ private: void addCustomOverlay() override; void LoadImageInternal(); + // Colour straight from the int32 detector image; image_fp is only materialised when the + // base class actually needs pixel values (ROI statistics, per-pixel labels). + void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const override; + void EnsurePixelValues() override; + bool pixel_values_valid_ = false; void DrawResolutionRings(); void DrawROIs(); void DrawAzimuthalROI(const ROIAzimuthal &az, const QColor &color, const DiffractionGeometry &geom); diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 1f7a9479..22f3f728 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -695,6 +695,48 @@ void JFJochImage::Redraw() { updateOverlay(); } +PixelColorMap JFJochImage::MakeColorMap() const { + // Bad pixel color + int r, g, b, a; + feature_color.getRgb(&r, &g, &b, &a); + auto bad_color = rgb{.r = static_cast(r), .g = static_cast(g), .b = static_cast(b)}; + + const auto &lut_data = color_scale.LUTData(); + const auto lutSize = static_cast(lut_data.size()); + const float lutScale = static_cast(lutSize - 1); + const float range = foreground - background; + + return PixelColorMap{ + .lut = lut_data.data(), + .lut_size = lutSize, + .minv = background, + .range = range, + .inv_range = (range > 0) ? (lutScale / range) : 0.0f, + .inv_range_log = (range > 0) ? (lutScale / std::log1p(range)) : 0.0f, + .hdr = hdr_mode, + .gap = color_scale.Apply(ColorScaleSpecial::Gap), + .bad = bad_color, + // Saturation color + .saturated = show_saturation ? bad_color : color_scale.Apply(1.0f), + }; +} + +void JFJochImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const { + const float *row = &image_fp[y * W]; + + for (size_t x = 0; x < W; ++x) { + const float fp = row[x]; + + rgb c; + if (!std::isfinite(fp)) + c = std::isnan(fp) ? map.gap : (std::signbit(fp) ? map.bad : map.saturated); + else + c = map.Apply(fp); + + out[x] = qRgb(c.r, c.g, c.b); + } +} + void JFJochImage::RenderImage() { if (qimg_buffer_.width() != int(W) || qimg_buffer_.height() != int(H)) qimg_buffer_ = QImage(int(W), int(H), QImage::Format_RGB32); @@ -704,73 +746,14 @@ void JFJochImage::RenderImage() { uchar *const bits = qimg_buffer_.bits(); const qsizetype stride = qimg_buffer_.bytesPerLine(); - // Bad pixel color - int r, g, b, a; - feature_color.getRgb(&r, &g, &b, &a); - auto bad_color = rgb{.r = static_cast(r), .g = static_cast(g), .b = static_cast(b)}; - - // Saturation color - rgb sat_color{}; - if (show_saturation) { - sat_color = bad_color; - } else - sat_color = color_scale.Apply(1.0f); - - // Precompute once - const float minv = background; - const float maxv = foreground; - - const auto &lut_data = color_scale.LUTData(); - const int64_t lutSize = lut_data.size(); - const float lutScale = static_cast(lutSize - 1); - const float range = maxv - minv; - const float invRange = (range > 0) ? (lutScale / range) : 0.0f; - const float invRangeLog = (range > 0) ? (lutScale / std::log1p(range)) : 0.0f; - - rgb gap_color = color_scale.Apply(ColorScaleSpecial::Gap); + const PixelColorMap map = MakeColorMap(); QVector rows; rows.reserve(H); for (int y = 0; y < H; ++y) rows.push_back(y); QtConcurrent::blockingMap(rows, [&](int y) { - QRgb *scanLine = reinterpret_cast(bits + y * stride); - const float *row = &image_fp[y * W]; - - for (int x = 0; x < W; ++x) { - const float fp = row[x]; - - rgb c; - if (!std::isfinite(fp)) { - if (std::isnan(fp)) { - c = gap_color; - } else { - c = std::signbit(fp) ? bad_color : sat_color; - } - } else { - float f; - const float fp_minv = fp - minv; - - if (hdr_mode) { - if (fp_minv <= 0.0f) - f = 0.0f; - else if (fp_minv >= range) - f = lutSize; - else - f = std::log1p(fp_minv) * invRangeLog; - } else - f = fp_minv * invRange; - - if (f < 0.0f) f = 0.0f; - - auto idx = static_cast(f + 0.5f); - if (idx <= 0) idx = 0; - else if (idx >= lutSize) idx = lutSize - 1; - c = lut_data[idx]; - } - - scanLine[x] = qRgb(c.r, c.g, c.b); - } + ColorRow(y, map, reinterpret_cast(bits + y * stride)); }); image_dirty_ = true; @@ -831,6 +814,8 @@ void JFJochImage::writePixelLabels() { constexpr float kMaxFixed = 1e5; if (visW * visH <= maxLabels) { + EnsurePixelValues(); + QString numBuf; // reused buffer for (int y = startY; y < endY; y ++) { @@ -981,6 +966,10 @@ void JFJochImage::CalcROI() { auto box_norm = roiBox.normalized(); + // accumulateROI only reads pixel values inside the box, so an empty ROI needs none + if (box_norm.width() > 0 && box_norm.height() > 0) + EnsurePixelValues(); + // Using the rectangle as-is; you can adjust inclusivity if needed int64_t xmin = box_norm.left(); int64_t xmax = box_norm.right(); diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index 7bf2f6e3..e94ccb85 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -3,6 +3,8 @@ #pragma once +#include + #include #include #include @@ -15,6 +17,42 @@ // Q_DECLARE_METATYPE(ROIMessage) +// Maps one pixel value to a colour. Shared by the generic float path and by subclasses that +// colour straight out of their own buffer, so the two cannot drift apart. Apply() takes a +// real value; the callers handle their own gap/bad/saturated encoding. +struct PixelColorMap { + const rgb *lut = nullptr; + int lut_size = 0; + float minv = 0.0f; + float range = 0.0f; + float inv_range = 0.0f; + float inv_range_log = 0.0f; + bool hdr = false; + rgb gap{}, bad{}, saturated{}; + + [[nodiscard]] rgb Apply(float v) const { + float f; + const float v_minv = v - minv; + + if (hdr) { + if (v_minv <= 0.0f) + f = 0.0f; + else if (v_minv >= range) + f = static_cast(lut_size); + else + f = std::log1p(v_minv) * inv_range_log; + } else + f = v_minv * inv_range; + + if (f < 0.0f) f = 0.0f; + + auto idx = static_cast(f + 0.5f); + if (idx <= 0) idx = 0; + else if (idx >= lut_size) idx = lut_size - 1; + return lut[idx]; + } +}; + // Draws the rendered frame straight out of JFJochImage::qimg_buffer_. A QGraphicsPixmapItem // would mean converting the whole image into a QPixmap on every recolour, which costs one // extra allocation and a full pass over the pixels. @@ -119,6 +157,14 @@ protected: void updateOverlay(); void RenderImage(); + PixelColorMap MakeColorMap() const; + // Colour one row into `out`. Called from worker threads, so it must stay const. The base + // maps image_fp; a subclass whose source is already a compact buffer can map that directly + // and skip materialising the float image. + virtual void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const; + // Fill image_fp, which the base class reads for ROI statistics and per-pixel value labels. + // Subclasses that colour without it fill it on demand here rather than on every frame. + virtual void EnsurePixelValues() {} // Re-render once the event queue drains. The foreground slider and the wheel emit far // faster than a large image can be recoloured, so intermediate values are dropped // instead of queueing a full recolour per event. -- 2.54.0 From 501ce1ba3dcdacbf95c0f7620d9407d7a19b4787 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 20:12:36 +0200 Subject: [PATCH 025/295] Viewer: rate-limit hover feedback to ~15 Hz The status bar, the resolution readout and the magnifier were all regenerated on every single mouse motion event. Each regeneration repaints, and on a remote X session a repaint uploads the whole window regardless of how little changed, so the pointer merely crossing the image saturates the link: measured with a counting relay in front of the X server, 50 motions over the image cost 273 MB, and a build with the hover work removed cost 18 KB. Rate-limit it. Two details matter: - The limit is applied inline, not from a timer. Running the update inside the mouse event keeps its damage in the same repaint as anything else that event triggers (a pan). A first attempt deferred the work to a timer instead, which split one repaint into two and made panning measurably worse. - The catch-up that reports the final position is debounced, not queued per skipped motion, so it fires once after the pointer stops rather than repeatedly mid-gesture. mouseHover() now takes the scene position and modifiers instead of the event, which also removes the identical mapToScene() from all four implementations. Hover traffic over 3 repeats: 173 MB mean -> 140 MB, and the run-to-run spread drops from +-14% to +-2%. The harness tops out near 30 motions/s, barely above the 15 Hz limit; a real mouse reports far faster, where the cap does more. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochAzIntImage.cpp | 3 +- viewer/image_viewer/JFJochAzIntImage.h | 2 +- .../image_viewer/JFJochDiffractionImage.cpp | 3 +- viewer/image_viewer/JFJochDiffractionImage.h | 2 +- viewer/image_viewer/JFJochGridScanImage.cpp | 6 ++-- viewer/image_viewer/JFJochGridScanImage.h | 2 +- viewer/image_viewer/JFJochImage.cpp | 28 +++++++++++++++++-- viewer/image_viewer/JFJochImage.h | 21 +++++++++++++- viewer/image_viewer/JFJochSimpleImage.cpp | 3 +- viewer/image_viewer/JFJochSimpleImage.h | 2 +- 10 files changed, 55 insertions(+), 17 deletions(-) diff --git a/viewer/image_viewer/JFJochAzIntImage.cpp b/viewer/image_viewer/JFJochAzIntImage.cpp index 449ee039..a9905f24 100644 --- a/viewer/image_viewer/JFJochAzIntImage.cpp +++ b/viewer/image_viewer/JFJochAzIntImage.cpp @@ -77,10 +77,9 @@ void JFJochAzIntImage::imageLoaded(std::shared_ptr in_i } } -void JFJochAzIntImage::mouseHover(QMouseEvent* event) { +void JFJochAzIntImage::mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers) { if (!scene() || !image || W == 0 || H == 0) return; - QPointF scenePos = mapToScene(event->pos()); int x = static_cast(scenePos.x()); int y = static_cast(scenePos.y()); diff --git a/viewer/image_viewer/JFJochAzIntImage.h b/viewer/image_viewer/JFJochAzIntImage.h index 08dff6d8..2eb9d3fc 100644 --- a/viewer/image_viewer/JFJochAzIntImage.h +++ b/viewer/image_viewer/JFJochAzIntImage.h @@ -21,7 +21,7 @@ class JFJochAzIntImage : public JFJochImage { float range_max = 1.0f; std::shared_ptr image; - void mouseHover(QMouseEvent* event) override; + void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; void Clear(); void mouseDoubleClickEvent(QMouseEvent *event) override; signals: diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 130aa865..1f5ad172 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -79,8 +79,7 @@ void JFJochDiffractionImage::azimuthalHandles(const ROIAzimuthal &az, const Diff phimax = pt(d_mid, phi1); } -void JFJochDiffractionImage::mouseHover(QMouseEvent *event) { - auto coord = mapToScene(event->pos()); +void JFJochDiffractionImage::mouseHover(const QPointF &coord, Qt::KeyboardModifiers) { if (image && (coord.x() >= 0) && (coord.x() < image->Dataset().experiment.GetXPixelsNum()) diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index e11e84ae..1d9bc6c3 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -102,7 +102,7 @@ private: float ice_ring_width_Q_recipA = 0.01; - void mouseHover(QMouseEvent* event) override; + void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; signals: void roiGeometryEdited(ROIDefinition rois); diff --git a/viewer/image_viewer/JFJochGridScanImage.cpp b/viewer/image_viewer/JFJochGridScanImage.cpp index a98121e6..7bb761fd 100644 --- a/viewer/image_viewer/JFJochGridScanImage.cpp +++ b/viewer/image_viewer/JFJochGridScanImage.cpp @@ -63,13 +63,11 @@ void JFJochGridScanImage::loadData(const std::vector &data, const GridSca CalcROI(); } -void JFJochGridScanImage::mouseHover(QMouseEvent *event) { +void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers modifiers) { // Map mouse position to image pixel if inside bounds if (W == 0 || H == 0 || image_index.empty()) return; - const QPointF pt = mapToScene(event->pos()); - // Convert view coordinates to image pixel by truncation int x = static_cast(pt.x()); int y = static_cast(pt.y()); @@ -82,7 +80,7 @@ void JFJochGridScanImage::mouseHover(QMouseEvent *event) { return; int64_t image_id = image_index[idx]; if (image_id >= 0) { - if (event->modifiers() & Qt::ShiftModifier) + if (modifiers & Qt::ShiftModifier) emit imageSelected(image_id); if (one_over_d2) { diff --git a/viewer/image_viewer/JFJochGridScanImage.h b/viewer/image_viewer/JFJochGridScanImage.h index 3853a01e..e290a7ef 100644 --- a/viewer/image_viewer/JFJochGridScanImage.h +++ b/viewer/image_viewer/JFJochGridScanImage.h @@ -17,7 +17,7 @@ class JFJochGridScanImage : public JFJochImage { int64_t current_image_H = -1; bool one_over_d2 = false; - void mouseHover(QMouseEvent *event) override; + void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; void mouseDoubleClickEvent(QMouseEvent *event) override; void loadImage(QMouseEvent *event); void addCustomOverlay() override; diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 22f3f728..ef165b21 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -61,6 +61,10 @@ JFJochImage::JFJochImage(QWidget *parent) : QGraphicsView(parent) { // Optional: a sensible default colormap color_scale.Select(ColorScaleEnum::Indigo); + + hover_tail_timer_ = new QTimer(this); + hover_tail_timer_->setSingleShot(true); + connect(hover_tail_timer_, &QTimer::timeout, this, &JFJochImage::UpdateHover); } void JFJochImage::onScroll(int value) { @@ -69,6 +73,27 @@ void JFJochImage::onScroll(int value) { updateOverlay(); } +void JFJochImage::UpdateHover() { + hover_rate_.restart(); + mouseHover(hover_scene_pos_, hover_modifiers_); + emit hoverScenePos(hover_scene_pos_); +} + +void JFJochImage::ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) { + hover_scene_pos_ = scenePos; + hover_modifiers_ = modifiers; + + if (!hover_rate_.isValid() || hover_rate_.elapsed() >= kHoverIntervalMs) { + hover_tail_timer_->stop(); + UpdateHover(); + return; + } + + // Too soon. Push the catch-up back instead of queueing one per skipped motion, so it fires + // once, after the pointer stops -- a catch-up that fires mid-gesture is an extra repaint. + hover_tail_timer_->start(kHoverIntervalMs); +} + void JFJochImage::ScheduleRenderImage() { if (render_pending_) return; @@ -244,8 +269,7 @@ void JFJochImage::mouseMoveEvent(QMouseEvent *event) { return; const QPointF scenePos = mapToScene(event->pos()); - mouseHover(event); - emit hoverScenePos(scenePos); + ScheduleHoverUpdate(scenePos, event->modifiers()); QPointF delta; switch (mouse_event_type) { diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index e94ccb85..2da38b3d 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -5,6 +5,8 @@ #include +#include +#include #include #include #include @@ -151,7 +153,24 @@ protected: QRectF roiBox; static QPointF RoundPoint(const QPointF& p); - virtual void mouseHover(QMouseEvent* event) = 0; + virtual void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) = 0; + + // Hover feedback (status bar, resolution readout, magnifier) used to be regenerated on every + // single mouse motion. Each regeneration dirties the window, and on a remote X session every + // repaint costs a full-window pixel upload, so the thing to minimise is the number of + // repaints. ~15 Hz is far below the motion event rate and well above what the eye follows. + // + // The rate limit is applied inline rather than from a timer on purpose: running the update + // inside the mouse event keeps its damage in the same repaint as anything else that event + // triggers (a pan), instead of costing a second one. The timer only covers the tail, so the + // final position is still reported once the pointer stops. + static constexpr int kHoverIntervalMs = 66; + void ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers); + void UpdateHover(); + QPointF hover_scene_pos_; + Qt::KeyboardModifiers hover_modifiers_ = Qt::NoModifier; + QElapsedTimer hover_rate_; + QTimer *hover_tail_timer_ = nullptr; ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const; diff --git a/viewer/image_viewer/JFJochSimpleImage.cpp b/viewer/image_viewer/JFJochSimpleImage.cpp index 25156a6e..75b43270 100644 --- a/viewer/image_viewer/JFJochSimpleImage.cpp +++ b/viewer/image_viewer/JFJochSimpleImage.cpp @@ -41,9 +41,8 @@ void JFJochSimpleImage::setImage(std::shared_ptr img) { } -void JFJochSimpleImage::mouseHover(QMouseEvent *event) { +void JFJochSimpleImage::mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers) { if (image_) { - const QPointF scenePos = mapToScene(event->pos()); // Hover feedback / status bar display if ((scenePos.x() >= 0) && (scenePos.x() < image_->image.GetWidth()) diff --git a/viewer/image_viewer/JFJochSimpleImage.h b/viewer/image_viewer/JFJochSimpleImage.h index 3232bc7c..92b78697 100644 --- a/viewer/image_viewer/JFJochSimpleImage.h +++ b/viewer/image_viewer/JFJochSimpleImage.h @@ -25,7 +25,7 @@ class JFJochSimpleImage : public JFJochImage { void loadImageInternal(const uint8_t *input); void loadImageInternal(); - void mouseHover(QMouseEvent *event) override; + void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; public: explicit JFJochSimpleImage(QWidget *parent = nullptr); public slots: -- 2.54.0 From 4892c571193d30d04ecc944d38dc62a8071fef6d Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 20:13:04 +0200 Subject: [PATCH 026/295] Viewer: paint the resolution readout in drawForeground, not as a scene item The hovered "d = ... A" readout was a QGraphicsTextItem flagged ItemIgnoresTransformations, repositioned on every mouse motion. Qt cannot compute a tight dirty rect for an item that ignores the view transform, so it marks the entire viewport dirty whenever such an item moves or changes text -- and this one moved constantly. Paint it in drawForeground() in viewport pixels instead, and repaint only the union of its old and new rectangles. That also removes the item lifetime special-casing: it was deliberately kept out of overlay_items_, had to be nulled by hand after scene()->clear(), and carried comments in three places warning about the dangling pointer. This does not reduce raw X11 traffic -- there every repaint uploads the whole window whatever the damage -- but it cuts the work per hover, and it does matter under a compressing remote protocol (VNC/NX/xpra), which encodes only the region that actually changed. Verified against the previous build: same text, colour and position. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 89 +++++++++---------- viewer/image_viewer/JFJochDiffractionImage.h | 7 +- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 1f5ad172..34ee23c7 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -449,8 +449,6 @@ void JFJochDiffractionImage::addCustomOverlay() { DrawPredictions(); if (show_saturation) DrawSaturation(); - - DrawResolutionText(); } void JFJochDiffractionImage::DrawROIs() { @@ -916,7 +914,7 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr scene()->clear(); resetScenePointers(); hover_resolution = NAN; - hover_resolution_item = nullptr; + DrawResolutionText(); CalcROI(); } @@ -1000,57 +998,56 @@ void JFJochDiffractionImage::setResolutionRingMode(RingMode mode) { updateOverlay(); } -void JFJochDiffractionImage::DrawResolutionText() { - auto scn = scene(); - if (!scn) { - hover_resolution_item = nullptr; // scene gone - return; - } - - // Hide item if no valid hover resolution - if (!image || !std::isfinite(hover_resolution) || hover_resolution <= 0.0f) { - if (hover_resolution_item) - hover_resolution_item->setVisible(false); - return; - } - - const QRectF visibleRect = mapToScene(viewport()->geometry()).boundingRect(); - - // Fixed on-screen font size (no dependence on scale_factor) +static QFont HoverResolutionFont() { QFont font("Arial"); - font.setPixelSize(32); // big, constant size on screen + font.setPixelSize(32); // big, constant size on screen + return font; +} - const QString label = - QString("d = %1 Å").arg(QString::number(hover_resolution, 'f', 2)); +QString JFJochDiffractionImage::HoverResolutionLabel() const { + if (!image || !std::isfinite(hover_resolution) || hover_resolution <= 0.0f) + return {}; + return QString("d = %1 \u00C5").arg(QString::number(hover_resolution, 'f', 2)); +} - // Create the item if it does not exist yet; otherwise reuse it - // NOTE: hover_resolution_item is NOT tracked in overlay_items_ — it is persistent - if (!hover_resolution_item) { - hover_resolution_item = scn->addText(label, font); - hover_resolution_item->setZValue(10.0); - // Make the text ignore zooming / view transforms - hover_resolution_item->setFlag(QGraphicsItem::ItemIgnoresTransformations, true); - } else { - hover_resolution_item->setFont(font); - hover_resolution_item->setPlainText(label); +void JFJochDiffractionImage::drawForeground(QPainter *painter, const QRectF &rect) { + JFJochImage::drawForeground(painter, rect); + + const QString label = HoverResolutionLabel(); + if (label.isEmpty()) + return; + + painter->save(); + painter->resetTransform(); // lay the readout out in viewport pixels, not scene units + painter->setFont(HoverResolutionFont()); + painter->setPen(feature_color); + painter->drawText(hover_text_rect_, Qt::AlignLeft | Qt::AlignTop, label); + painter->restore(); +} + +void JFJochDiffractionImage::DrawResolutionText() { + const QRect previous = hover_text_rect_; + const QString label = HoverResolutionLabel(); + + if (label.isEmpty()) + hover_text_rect_ = QRect(); + else { + constexpr int margin_px = 10; + const QFontMetrics fm(HoverResolutionFont()); + hover_text_rect_ = QRect(QPoint(margin_px, margin_px), fm.size(0, label)); } - hover_resolution_item->setDefaultTextColor(feature_color); - - // Keep a roughly constant ~10 px margin by compensating with scale_factor - const qreal margin_px = 10.0; - const qreal margin_scene = margin_px / std::max(0.0001, scale_factor); - - QPointF topLeft(visibleRect.left() + margin_scene, - visibleRect.top() + margin_scene); - hover_resolution_item->setPos(topLeft); - hover_resolution_item->setVisible(true); + // Repaint just the readout. The previous version was a QGraphicsItem flagged + // ItemIgnoresTransformations, which makes Qt mark the whole viewport dirty every time the + // item moves or its text changes - and it moved on every mouse motion. + const QRect dirty = previous.united(hover_text_rect_).adjusted(-2, -2, 2, 2); + if (!dirty.isEmpty()) + viewport()->update(dirty); } void JFJochDiffractionImage::beforeOverlayCleared() { - // hover_resolution_item is NOT in overlay_items_, so the selective clear won't touch it. - // However, if scene()->clear() is ever called (e.g. on loadImage(nullptr)), - // the caller must also set hover_resolution_item = nullptr separately. + // The resolution readout is painted in drawForeground(), not held as a scene item, so + // clearing the overlay (or the whole scene) cannot leave a dangling pointer behind. } void JFJochDiffractionImage::leaveEvent(QEvent *event) { diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 1d9bc6c3..c9a3f847 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -21,7 +21,12 @@ Q_OBJECT QColor second_lattice_color = QColor(0xFA, 0x72, 0x68); // coral, the viewer "finishing" accent float hover_resolution = NAN; - QGraphicsTextItem* hover_resolution_item = nullptr; // big text in top-left + // The "d = ... A" readout is painted in drawForeground() in viewport pixels rather than kept + // as a scene item, so updating it dirties only its own rect. hover_text_rect_ is where it + // currently sits, in viewport coordinates. + QRect hover_text_rect_; + [[nodiscard]] QString HoverResolutionLabel() const; + void drawForeground(QPainter *painter, const QRectF &rect) override; public: enum class RingMode {Auto, Estimation, Manual, None, IceRings}; Q_ENUM(RingMode) -- 2.54.0 From 2a31cf8d81632f00cfe04b96e1c771bd116b401b Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 20:13:21 +0200 Subject: [PATCH 027/295] Viewer: stop forcing FullViewportUpdate in JFJochSimpleImage FullViewportUpdate redraws the whole viewport on any change. The attached comment ("keep overlays in pixel units independent of zoom") does not describe what the setting does, and nothing here needs it: SmartViewportUpdate repaints the changed rectangles and falls back to a full repaint by itself once there are too many to be worth tracking. This is the view used by the calibration window and the magnifier, and the magnifier is driven from every hover, so on a remote session it repainted its whole viewport per pointer motion. Note: not exercised visually -- both windows are opened from menus, which the headless harness does not drive. The change is a repaint-mode switch with no effect on what is drawn. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochSimpleImage.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/viewer/image_viewer/JFJochSimpleImage.cpp b/viewer/image_viewer/JFJochSimpleImage.cpp index 75b43270..5e561cfa 100644 --- a/viewer/image_viewer/JFJochSimpleImage.cpp +++ b/viewer/image_viewer/JFJochSimpleImage.cpp @@ -16,8 +16,9 @@ JFJochSimpleImage::JFJochSimpleImage(QWidget *parent) auto *scn = new QGraphicsScene(this); setScene(scn); - // Keep overlays in pixel units independent of zoom (for labels font sizing) - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); + // Repaint only what changed. FullViewportUpdate redraws the entire viewport on any change, + // which is wasted work locally and, on a remote session, uploads far more than was touched. + setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); // The predicted/float image is unreadable with 3-decimal per-pixel labels. label_decimals_ = 1; -- 2.54.0 From 68f5f1f32da7aff6074d9c6a45df363328bf9445 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 20:45:11 +0200 Subject: [PATCH 028/295] Viewer: do not render the magnifier close-up while it is closed centerAt() checked isVisible(), but imageLoaded() did not, so every frame built a SimpleImage over the whole detector image and ran it through the full JFJochSimpleImage path -- convert to float, colour every pixel, redraw -- to feed a 320x320 window that is closed by default and stays closed most of the time. Remember the frame instead and do the work in showEvent(). Holding the shared_ptr also keeps alive the buffer that the SimpleImage's CompressedImage points into, which it did not own. Stepping 30 frames with the magnifier closed: 5545 -> 4770 ms CPU (-14%), on a 2.8 Mpx detector; the saving is per-pixel, so it grows with detector size. With the magnifier open the cost is unchanged (5500 ms), which is what was being paid unconditionally before. Verified in the GUI: opening the magnifier still populates it, and it still refreshes when the frame changes. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/windows/JFJochMagnifierWindow.cpp | 21 +++++++++++++++++++++ viewer/windows/JFJochMagnifierWindow.h | 9 +++++++++ 2 files changed, 30 insertions(+) diff --git a/viewer/windows/JFJochMagnifierWindow.cpp b/viewer/windows/JFJochMagnifierWindow.cpp index 06f1f109..198072bf 100644 --- a/viewer/windows/JFJochMagnifierWindow.cpp +++ b/viewer/windows/JFJochMagnifierWindow.cpp @@ -5,6 +5,7 @@ #include "../image_viewer/JFJochSimpleImage.h" #include "../SimpleImage.h" +#include #include JFJochMagnifierWindow::JFJochMagnifierWindow(QWidget *parent) @@ -17,6 +18,26 @@ JFJochMagnifierWindow::JFJochMagnifierWindow(QWidget *parent) } void JFJochMagnifierWindow::imageLoaded(std::shared_ptr image) { + m_pending_image = std::move(image); + // The window is closed most of the time, and rendering a close-up nobody is looking at costs + // a full conversion and recolour of the whole detector image on every frame. + if (!isVisible()) { + m_pending_dirty = true; + return; + } + ApplyPendingImage(); +} + +void JFJochMagnifierWindow::showEvent(QShowEvent *event) { + JFJochHelperWindow::showEvent(event); + if (m_pending_dirty) + ApplyPendingImage(); +} + +void JFJochMagnifierWindow::ApplyPendingImage() { + m_pending_dirty = false; + const std::shared_ptr &image = m_pending_image; + if (!image) { m_have_image = false; m_image->setImage(nullptr); diff --git a/viewer/windows/JFJochMagnifierWindow.h b/viewer/windows/JFJochMagnifierWindow.h index 91ec2621..5676155a 100644 --- a/viewer/windows/JFJochMagnifierWindow.h +++ b/viewer/windows/JFJochMagnifierWindow.h @@ -18,6 +18,15 @@ class JFJochMagnifierWindow : public JFJochHelperWindow { double m_magnification = 12.0; bool m_have_image = false; + // Building the close-up converts and colours the whole detector image, so it is only done + // while the window is actually up. The frame is remembered either way; holding the + // shared_ptr also keeps alive the buffer the SimpleImage points into. + std::shared_ptr m_pending_image; + bool m_pending_dirty = false; + void ApplyPendingImage(); + + void showEvent(QShowEvent *event) override; + public: explicit JFJochMagnifierWindow(QWidget *parent = nullptr); -- 2.54.0 From 5782cc0edf1460831e10c6858bff34e2131a61f6 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:05:21 +0200 Subject: [PATCH 029/295] Viewer: reciprocal-space view does nothing while its window is closed The window is a placeholder for future functionality and is closed almost all of the time, but it extracted the frame's spots and rebuilt and uploaded its vertex arrays on every image, whether or not anything was on screen. Guard it in rebuildGL() rather than at each of the eight call sites, so any future caller inherits the behaviour: while hidden it only records that a rebuild is owed, and showEvent() pays it. imageLoaded() additionally skips extracting the frame's spots, which is the other half of the per-frame work. The OpenGL code path is untouched and still built and exercised the moment the window is opened. Note: I could not show a CPU saving for this on the headless test machine -- there, ~74% of the process CPU is Mesa llvmpipe software rasterisation that I was unable to attribute to any per-frame code path, and it swamps the effect. The work being skipped is nonetheless unambiguously unnecessary. Verified in the GUI: after stepping frames with the window closed, opening it shows the current frame's spots, and it keeps updating while open. Co-Authored-By: Claude Opus 5 (1M context) --- .../JFJochViewerReciprocalSpaceWindow.cpp | 29 +++++++++++++++++++ .../JFJochViewerReciprocalSpaceWindow.h | 5 ++++ 2 files changed, 34 insertions(+) diff --git a/viewer/windows/JFJochViewerReciprocalSpaceWindow.cpp b/viewer/windows/JFJochViewerReciprocalSpaceWindow.cpp index 0eb41f23..1c992796 100644 --- a/viewer/windows/JFJochViewerReciprocalSpaceWindow.cpp +++ b/viewer/windows/JFJochViewerReciprocalSpaceWindow.cpp @@ -472,6 +472,12 @@ void JFJochViewerReciprocalSpaceWindow::imageLoaded(std::shared_ptrisChecked() && has_rotation_; std::optional plot_lattice; diff --git a/viewer/windows/JFJochViewerReciprocalSpaceWindow.h b/viewer/windows/JFJochViewerReciprocalSpaceWindow.h index 67d5a6c1..22a3d619 100644 --- a/viewer/windows/JFJochViewerReciprocalSpaceWindow.h +++ b/viewer/windows/JFJochViewerReciprocalSpaceWindow.h @@ -143,7 +143,12 @@ private: }; QColor spotColorFor(bool indexed, bool ice_ring) const; + // rebuildGL() is a no-op while the window is closed - it only records that a rebuild is + // owed, and showEvent() pays it. rebuildGLNow() is the actual work. void rebuildGL(); // rebuilds both spot and line vertex data + void rebuildGLNow(); + void showEvent(QShowEvent *event) override; + bool pending_rebuild_ = false; void loadCurrentImageSpots(std::shared_ptr image); void addSpot(const SpotToSave &s, const DiffractionGeometry &geom, -- 2.54.0 From d2ce65f85792b5f56d3d1b201965fe0625a33d89 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:11:42 +0200 Subject: [PATCH 030/295] Viewer: magnifier displays the frame the main view already rendered The magnifier and the main view are two views of the same image at different position and zoom, but the magnifier ran the whole pipeline again on its own copy: it wrapped the same int32 buffer in a SimpleImage, converted it to float, coloured every pixel and kept its own full-size QImage. That is a second conversion and two extra full-detector buffers (20 MB at 2.8 Mpx, 138 MB at 18 Mpx) to feed a 320x320 window. Separate producing a frame from displaying one: - JFJochImage keeps the rendered frame in a shared_ptr (the pointer is stable for the widget's lifetime; only the contents change, so the existing buffer reuse is unaffected), publishes it via Frame() and announces new pixels with frameRendered(). - JFJochImageItem holds that shared_ptr instead of a reference to a member of its owner, which also removes a lifetime coupling. - JFJochFollowerImage is a small read-only view of such a frame with its own zoom and centre. It shows only the image: overlays, ROI tools and per-pixel labels belong to the view that owns the data. - The magnifier becomes one of those, fed from frameRendered(). Consequences beyond the saving: the magnifier now agrees with the main view on colour map, contrast and HDR mode, which it never did -- it was wired to neither, so it always drew with its own defaults. And the visibility guard added in 6d1af4921 is gone: there is no longer any per-frame work to skip, so nothing needs guarding. That guard was a workaround for this design. Stepping 30 frames with the magnifier open: 5550 -> 4810 ms CPU, which is what it costs with the magnifier closed (4770 ms) -- it is now free either way. Verified: main image panel and a drag-pan stay pixel-identical to the pre-refactor binary (AE=0); the magnifier follows the cursor, updates on a new frame, and now tracks a colour-map change. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/CMakeLists.txt | 2 + viewer/JFJochViewerWindow.cpp | 7 ++- .../image_viewer/JFJochDiffractionImage.cpp | 1 + viewer/image_viewer/JFJochFollowerImage.cpp | 60 +++++++++++++++++++ viewer/image_viewer/JFJochFollowerImage.h | 32 ++++++++++ viewer/image_viewer/JFJochImage.cpp | 33 ++++++---- viewer/image_viewer/JFJochImage.h | 23 ++++--- viewer/windows/JFJochMagnifierWindow.cpp | 57 +++--------------- viewer/windows/JFJochMagnifierWindow.h | 28 ++++----- 9 files changed, 154 insertions(+), 89 deletions(-) create mode 100644 viewer/image_viewer/JFJochFollowerImage.cpp create mode 100644 viewer/image_viewer/JFJochFollowerImage.h diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index a6cc9e78..9c311a5b 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -89,6 +89,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView windows/JFJochLicenseWindow.h image_viewer/JFJochImage.cpp image_viewer/JFJochImage.h + image_viewer/JFJochFollowerImage.cpp + image_viewer/JFJochFollowerImage.h windows/JFJoch2DAzintImageWindow.cpp windows/JFJoch2DAzintImageWindow.h widgets/JFJochViewerROIResult.cpp diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 0c323170..82fade34 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -395,8 +395,11 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString viewer, &JFJochDiffractionImage::centerOnSpot); // --- Magnifier --- - connect(this, &JFJochViewerWindow::imageReady, - magnifierWindow, &JFJochHelperWindow::imageLoaded); + // The magnifier shows the frame the main view has already rendered - the same pixels with + // its own zoom and centre - so it neither converts nor colours anything itself. + connect(viewer, &JFJochImage::frameRendered, magnifierWindow, [viewer, magnifierWindow] { + magnifierWindow->setFrame(viewer->Frame()); + }); connect(viewer, &JFJochImage::hoverScenePos, magnifierWindow, &JFJochMagnifierWindow::centerAt); diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 34ee23c7..5f7b3576 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -910,6 +910,7 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr } else { image.reset(); W = 0; H = 0; + ClearFrame(); // followers (magnifier) must not keep showing the old frame if (scene()) scene()->clear(); resetScenePointers(); diff --git a/viewer/image_viewer/JFJochFollowerImage.cpp b/viewer/image_viewer/JFJochFollowerImage.cpp new file mode 100644 index 00000000..83d92126 --- /dev/null +++ b/viewer/image_viewer/JFJochFollowerImage.cpp @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochFollowerImage.h" +#include "JFJochImage.h" + +#include + +#include +#include + +JFJochFollowerImage::JFJochFollowerImage(QWidget *parent) : QGraphicsView(parent) { + setScene(new QGraphicsScene(this)); + setTransformationAnchor(QGraphicsView::AnchorViewCenter); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setTransform(QTransform::fromScale(zoom_, zoom_)); +} + +void JFJochFollowerImage::SetFrame(std::shared_ptr frame) { + const bool same_pointer = (frame_ == frame); + const bool same_size = frame_ && frame && frame_->size() == frame->size(); + frame_ = std::move(frame); + + if (!frame_ || frame_->isNull()) { + scene()->clear(); + item_ = nullptr; + return; + } + + // The producer reuses one buffer, so normally only the pixels changed and the item stays. + if (!item_ || !same_pointer) { + scene()->clear(); + item_ = new JFJochImageItem(frame_); + scene()->addItem(item_); + } + + if (!same_size) { + item_->refresh(); + scene()->setSceneRect(0, 0, frame_->width(), frame_->height()); + } + + viewport()->update(); +} + +void JFJochFollowerImage::CenterAt(QPointF scenePos) { + if (!frame_ || frame_->isNull()) + return; + centerOn(scenePos); +} + +void JFJochFollowerImage::wheelEvent(QWheelEvent *event) { + constexpr double step = 1.15; + zoom_ *= (event->angleDelta().y() > 0) ? step : 1.0 / step; + zoom_ = std::clamp(zoom_, 1.0, 200.0); + + const QPointF center = mapToScene(viewport()->rect().center()); + setTransform(QTransform::fromScale(zoom_, zoom_)); + centerOn(center); +} diff --git a/viewer/image_viewer/JFJochFollowerImage.h b/viewer/image_viewer/JFJochFollowerImage.h new file mode 100644 index 00000000..0c484f79 --- /dev/null +++ b/viewer/image_viewer/JFJochFollowerImage.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include +#include + +class JFJochImageItem; + +// A second view of a frame that a JFJochImage has already rendered: the same pixels, with its own +// zoom and centre. Nothing is converted or coloured here and there is no second full-size buffer, +// so following the main view costs a pointer assignment per frame rather than a whole render. +// +// It shows only the image. Overlays, ROI tools and per-pixel value labels belong to the view that +// owns the data; a magnifier does not need them. +class JFJochFollowerImage : public QGraphicsView { + Q_OBJECT + + JFJochImageItem *item_ = nullptr; + std::shared_ptr frame_; + double zoom_ = 12.0; + + void wheelEvent(QWheelEvent *event) override; +public: + explicit JFJochFollowerImage(QWidget *parent = nullptr); + + void SetFrame(std::shared_ptr frame); + void CenterAt(QPointF scenePos); +}; diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index ef165b21..26172d87 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -20,7 +20,7 @@ #include QRectF JFJochImageItem::boundingRect() const { - return QRectF(0, 0, img_.width(), img_.height()); + return img_ ? QRectF(0, 0, img_->width(), img_->height()) : QRectF(); } QPainterPath JFJochImageItem::opaqueArea() const { @@ -31,12 +31,12 @@ QPainterPath JFJochImageItem::opaqueArea() const { } void JFJochImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { - if (img_.isNull()) + if (!img_ || img_->isNull()) return; // QGraphicsPixmapItem defaults to Qt::FastTransformation and turned this hint off before // drawing; keep doing that, so zoomed-in detector pixels stay sharp squares. painter->setRenderHint(QPainter::SmoothPixmapTransform, false); - painter->drawImage(0, 0, img_); + painter->drawImage(0, 0, *img_); } void JFJochImageItem::refresh() { @@ -402,7 +402,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) { QAction *fitAct = menu.addAction(tr("Fit image to view")); QAction *clearRoiAct = menu.addAction(tr("Clear ROI")); - const bool hasImage = (W > 0 && H > 0 && !qimg_buffer_.isNull()); + const bool hasImage = (W > 0 && H > 0 && !frame_->isNull()); copyImageAct->setEnabled(hasImage); copyWithOverlayAct->setEnabled(hasImage && scene()); saveImageAct->setEnabled(hasImage); @@ -462,7 +462,7 @@ QImage JFJochImage::renderToImage(bool with_overlay) { p.end(); } else { // The underlying rendered image (no overlay) - img = qimg_buffer_; + img = *frame_; } // Ensure 1:1 pixel ratio and 96 DPI metadata to avoid rescaling in consumer apps img.setDevicePixelRatio(1.0); @@ -473,7 +473,7 @@ QImage JFJochImage::renderToImage(bool with_overlay) { } void JFJochImage::copyImageToClipboard() { - if (W == 0 || H == 0 || qimg_buffer_.isNull()) return; + if (W == 0 || H == 0 || frame_->isNull()) return; setClipboardAsJpegAndImage(renderToImage(false), 95); emit writeStatusBar(tr("Image copied to clipboard"), 2000); @@ -487,7 +487,7 @@ void JFJochImage::copyImageWithOverlayToClipboard() { } void JFJochImage::saveImageToFile(bool with_overlay) { - if (W == 0 || H == 0 || qimg_buffer_.isNull()) return; + if (W == 0 || H == 0 || frame_->isNull()) return; if (with_overlay && !scene()) return; const QString caption = with_overlay ? tr("Save image with overlay as JPEG") @@ -762,13 +762,13 @@ void JFJochImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const } void JFJochImage::RenderImage() { - if (qimg_buffer_.width() != int(W) || qimg_buffer_.height() != int(H)) - qimg_buffer_ = QImage(int(W), int(H), QImage::Format_RGB32); + if (frame_->width() != int(W) || frame_->height() != int(H)) + *frame_ = QImage(int(W), int(H), QImage::Format_RGB32); // Take the data pointer once, here: scanLine() is non-const, so calling it from the // workers below would have each of them detach the (possibly shared) buffer in parallel. - uchar *const bits = qimg_buffer_.bits(); - const qsizetype stride = qimg_buffer_.bytesPerLine(); + uchar *const bits = frame_->bits(); + const qsizetype stride = frame_->bytesPerLine(); const PixelColorMap map = MakeColorMap(); @@ -781,6 +781,13 @@ void JFJochImage::RenderImage() { }); image_dirty_ = true; + emit frameRendered(); +} + +void JFJochImage::ClearFrame() { + *frame_ = QImage(); + image_dirty_ = true; + emit frameRendered(); } void JFJochImage::centerOnSpot(QPointF point) { @@ -875,7 +882,7 @@ void JFJochImage::writePixelLabels() { textItem->setFont(font); // Read the colour back from the rendered image rather than keeping a // full-size mirror of it around for the few pixels that get a label. - const QRgb pxl = qimg_buffer_.pixel(x, y); + const QRgb pxl = frame_->pixel(x, y); if (luminance(rgb{.r = static_cast(qRed(pxl)), .g = static_cast(qGreen(pxl)), .b = static_cast(qBlue(pxl))}) > 128.0) @@ -912,7 +919,7 @@ void JFJochImage::updateOverlay() { // dirty, which forces a full repaint of the viewport, so only do it when the image // really changed - not on every pan and zoom. if (!image_item_) { - image_item_ = new JFJochImageItem(qimg_buffer_); + image_item_ = new JFJochImageItem(frame_); image_item_->setZValue(0); scene()->addItem(image_item_); image_dirty_ = false; diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index 2da38b3d..b0b1da6d 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -4,6 +4,7 @@ #pragma once #include +#include #include #include @@ -55,13 +56,13 @@ struct PixelColorMap { } }; -// Draws the rendered frame straight out of JFJochImage::qimg_buffer_. A QGraphicsPixmapItem -// would mean converting the whole image into a QPixmap on every recolour, which costs one -// extra allocation and a full pass over the pixels. +// Draws a rendered frame. A QGraphicsPixmapItem would mean converting the whole image into a +// QPixmap on every recolour, which costs one extra allocation and a full pass over the pixels. +// The frame is held by shared_ptr so that several views can show the same pixels. class JFJochImageItem : public QGraphicsItem { - const QImage &img_; + std::shared_ptr img_; public: - explicit JFJochImageItem(const QImage &img) : img_(img) {} + explicit JFJochImageItem(std::shared_ptr img) : img_(std::move(img)) {} QRectF boundingRect() const override; QPainterPath opaqueArea() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; @@ -119,7 +120,10 @@ protected: float background = 0.0; ColorScale color_scale; std::vector image_fp; - QImage qimg_buffer_; // reusable image buffer — avoids 64MB alloc per frame + // The rendered RGB frame. Reused across frames (no per-frame allocation) and held by + // shared_ptr so follower views can display the very same pixels - see JFJochFollowerImage. + // The pointer itself is stable for the lifetime of the widget; only its contents change. + std::shared_ptr frame_ = std::make_shared(); // Persistent image item — never destroyed/recreated on overlay update JFJochImageItem *image_item_ = nullptr; @@ -189,7 +193,8 @@ protected: // instead of queueing a full recolour per event. void ScheduleRenderImage(); bool render_pending_ = false; - bool image_dirty_ = false; // qimg_buffer_ changed since the item was last refreshed + bool image_dirty_ = false; // frame_ changed since the item was last refreshed + void ClearFrame(); // drop the pixels and tell followers // Set while a pan/zoom moves the scrollbars, so onScroll() does not rebuild the overlay // once per scrollbar; the gesture rebuilds it once itself. bool suppress_overlay_update_ = false; @@ -220,6 +225,8 @@ signals: void roiCalculated(ROIMessage &output); void viewportChanged(QTransform transform, QPointF center); void hoverScenePos(QPointF scenePos); + // A new frame has been rendered into Frame(). Follower views repaint on this. + void frameRendered(); private slots: void onScroll(int value); public slots: @@ -240,4 +247,6 @@ public slots: public: explicit JFJochImage(QWidget *parent = nullptr); double GetScaleFactor() const; + // The rendered frame, for views that want to show the same pixels without redoing the work + [[nodiscard]] std::shared_ptr Frame() const { return frame_; } }; \ No newline at end of file diff --git a/viewer/windows/JFJochMagnifierWindow.cpp b/viewer/windows/JFJochMagnifierWindow.cpp index 198072bf..52e6da7b 100644 --- a/viewer/windows/JFJochMagnifierWindow.cpp +++ b/viewer/windows/JFJochMagnifierWindow.cpp @@ -2,65 +2,24 @@ // SPDX-License-Identifier: GPL-3.0-only #include "JFJochMagnifierWindow.h" -#include "../image_viewer/JFJochSimpleImage.h" -#include "../SimpleImage.h" - -#include -#include +#include "../image_viewer/JFJochFollowerImage.h" JFJochMagnifierWindow::JFJochMagnifierWindow(QWidget *parent) : JFJochHelperWindow(parent) { setWindowTitle("Magnifier"); - m_image = new JFJochSimpleImage(this); - m_image->setZoom(m_magnification); + m_image = new JFJochFollowerImage(this); setCentralWidget(m_image); resize(320, 320); } -void JFJochMagnifierWindow::imageLoaded(std::shared_ptr image) { - m_pending_image = std::move(image); - // The window is closed most of the time, and rendering a close-up nobody is looking at costs - // a full conversion and recolour of the whole detector image on every frame. - if (!isVisible()) { - m_pending_dirty = true; - return; - } - ApplyPendingImage(); -} - -void JFJochMagnifierWindow::showEvent(QShowEvent *event) { - JFJochHelperWindow::showEvent(event); - if (m_pending_dirty) - ApplyPendingImage(); -} - -void JFJochMagnifierWindow::ApplyPendingImage() { - m_pending_dirty = false; - const std::shared_ptr &image = m_pending_image; - - if (!image) { - m_have_image = false; - m_image->setImage(nullptr); - return; - } - - const double scale = m_have_image ? m_image->GetScaleFactor() : m_magnification; - const QPointF center = m_have_image - ? m_image->mapToScene(m_image->viewport()->rect().center()) - : QPointF(image->Dataset().experiment.GetXPixelsNum() * 0.5, - image->Dataset().experiment.GetYPixelsNum() * 0.5); - - const auto &exp = image->Dataset().experiment; - auto si = std::make_shared(); - si->image = CompressedImage(image->Image(), exp.GetXPixelsNum(), exp.GetYPixelsNum()); - m_image->setImage(si); - m_image->applyViewport(QTransform::fromScale(scale, scale), center); - m_have_image = true; +void JFJochMagnifierWindow::setFrame(std::shared_ptr frame) { + // Just a pointer assignment plus an update() that a hidden window never acts on, so this + // needs no visibility guard: there is nothing expensive left to skip. + m_image->SetFrame(std::move(frame)); } void JFJochMagnifierWindow::centerAt(QPointF scenePos) { - if (!m_have_image || !isVisible()) + if (!isVisible()) return; - double scale = m_image->GetScaleFactor(); - m_image->applyViewport(QTransform::fromScale(scale, scale), scenePos); + m_image->CenterAt(scenePos); } diff --git a/viewer/windows/JFJochMagnifierWindow.h b/viewer/windows/JFJochMagnifierWindow.h index 5676155a..346be210 100644 --- a/viewer/windows/JFJochMagnifierWindow.h +++ b/viewer/windows/JFJochMagnifierWindow.h @@ -3,35 +3,27 @@ #pragma once +#include + #include "JFJochHelperWindow.h" #include -class JFJochSimpleImage; +class JFJochFollowerImage; +class QImage; -// ADXV-style magnifier: a small window showing a high-zoom close-up of the main -// image that follows the cursor. Fed the original image (converted to a -// SimpleImage) and re-centered on each hover position. +// ADXV-style magnifier: a small window showing a high-zoom close-up of the main image that +// follows the cursor. It displays the frame the main view has already rendered, so it does no +// conversion or colouring of its own and always agrees with the main view on colour map, +// contrast and HDR mode. class JFJochMagnifierWindow : public JFJochHelperWindow { Q_OBJECT - JFJochSimpleImage *m_image; - double m_magnification = 12.0; - bool m_have_image = false; - - // Building the close-up converts and colours the whole detector image, so it is only done - // while the window is actually up. The frame is remembered either way; holding the - // shared_ptr also keeps alive the buffer the SimpleImage points into. - std::shared_ptr m_pending_image; - bool m_pending_dirty = false; - void ApplyPendingImage(); - - void showEvent(QShowEvent *event) override; + JFJochFollowerImage *m_image; public: explicit JFJochMagnifierWindow(QWidget *parent = nullptr); - void imageLoaded(std::shared_ptr image) override; - public slots: + void setFrame(std::shared_ptr frame); void centerAt(QPointF scenePos); }; -- 2.54.0 From 7a893bb1e76f66522c1c835c845687dd73e53d0b Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:18:20 +0200 Subject: [PATCH 031/295] Viewer: per-pixel counts in the magnifier, read from the int32 image Users expect a magnifier to tell them the counts, which the follower view could not do: it has the rendered pixels but not the numbers behind them. Take them from the detector's int32 buffer directly, the same source the main view colours from, so no float copy of the image is needed - the magnifier still holds nothing full-size of its own, only a shared_ptr to the frame and one to the reader image. The labels are painted in drawForeground() rather than as scene items. The main view creates up to 5000 QGraphicsSimpleTextItems per overlay rebuild for this; here they are just drawn, so there is no item churn and no scene invalidation. Text is laid out in viewport pixels so it stays a constant readable size, and black/white is chosen from the luminance of the rendered pixel underneath, as the main view does. Threshold is the same 30x as the main view, so the default 12x magnification shows no labels until the user wheels in; a cap keeps pathological window sizes from drawing thousands of them. Verified in the GUI at 32x: counts drawn per pixel with white text over the dark centre of a Bragg peak and black elsewhere, and "Gap" across a module gap. Main image panel still pixel-identical to the pre-series baseline. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/JFJochViewerWindow.cpp | 4 ++ viewer/image_viewer/JFJochFollowerImage.cpp | 62 +++++++++++++++++++++ viewer/image_viewer/JFJochFollowerImage.h | 16 +++++- viewer/windows/JFJochMagnifierWindow.cpp | 4 ++ viewer/windows/JFJochMagnifierWindow.h | 4 ++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 82fade34..56ea0a8f 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -400,6 +400,10 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString connect(viewer, &JFJochImage::frameRendered, magnifierWindow, [viewer, magnifierWindow] { magnifierWindow->setFrame(viewer->Frame()); }); + + // ... and the raw counts behind it, for the per-pixel labels. Stores a pointer, nothing more. + connect(this, &JFJochViewerWindow::imageReady, + magnifierWindow, &JFJochHelperWindow::imageLoaded); connect(viewer, &JFJochImage::hoverScenePos, magnifierWindow, &JFJochMagnifierWindow::centerAt); diff --git a/viewer/image_viewer/JFJochFollowerImage.cpp b/viewer/image_viewer/JFJochFollowerImage.cpp index 83d92126..95761db5 100644 --- a/viewer/image_viewer/JFJochFollowerImage.cpp +++ b/viewer/image_viewer/JFJochFollowerImage.cpp @@ -3,12 +3,26 @@ #include "JFJochFollowerImage.h" #include "JFJochImage.h" +#include "../../common/ColorScale.h" #include +#include #include +#include #include +// Same wording as the main view's per-pixel labels +static QString PixelValueText(int32_t value) { + if (value == GAP_PXL_VALUE) + return QStringLiteral("Gap"); + if (value == ERROR_PXL_VALUE) + return QStringLiteral("Err"); + if (value == SATURATED_PXL_VALUE) + return QStringLiteral("Sat"); + return QString::number(value); +} + JFJochFollowerImage::JFJochFollowerImage(QWidget *parent) : QGraphicsView(parent) { setScene(new QGraphicsScene(this)); setTransformationAnchor(QGraphicsView::AnchorViewCenter); @@ -43,6 +57,10 @@ void JFJochFollowerImage::SetFrame(std::shared_ptr frame) { viewport()->update(); } +void JFJochFollowerImage::SetPixelValues(std::shared_ptr image) { + values_ = std::move(image); +} + void JFJochFollowerImage::CenterAt(QPointF scenePos) { if (!frame_ || frame_->isNull()) return; @@ -58,3 +76,47 @@ void JFJochFollowerImage::wheelEvent(QWheelEvent *event) { setTransform(QTransform::fromScale(zoom_, zoom_)); centerOn(center); } + +void JFJochFollowerImage::drawForeground(QPainter *painter, const QRectF &rect) { + QGraphicsView::drawForeground(painter, rect); + + if (zoom_ < kLabelZoom || !values_ || !frame_ || frame_->isNull()) + return; + + const int W = frame_->width(); + const int H = frame_->height(); + const auto &pixels = values_->Image(); + if (static_cast(pixels.size()) < static_cast(W) * H) + return; // values belong to a different frame + + const QRectF visible = mapToScene(viewport()->rect()).boundingRect(); + const int x0 = std::max(0, static_cast(std::floor(visible.left()))); + const int x1 = std::min(W, static_cast(std::ceil(visible.right()))); + const int y0 = std::max(0, static_cast(std::floor(visible.top()))); + const int y1 = std::min(H, static_cast(std::ceil(visible.bottom()))); + if (x1 <= x0 || y1 <= y0 || (x1 - x0) * (y1 - y0) > kMaxLabels) + return; + + // Lay the text out in viewport pixels so it stays a constant, readable size + painter->save(); + painter->resetTransform(); + + QFont font("DejaVu Sans Mono"); + font.setStyleHint(QFont::TypeWriter); + font.setPixelSize(std::clamp(static_cast(zoom_ * 0.3), 7, 16)); + painter->setFont(font); + + for (int y = y0; y < y1; y++) { + for (int x = x0; x < x1; x++) { + const QRect cell = mapFromScene(QRectF(x, y, 1, 1)).boundingRect(); + const QRgb c = frame_->pixel(x, y); + const rgb col{.r = static_cast(qRed(c)), + .g = static_cast(qGreen(c)), + .b = static_cast(qBlue(c))}; + painter->setPen(luminance(col) > 128.0 ? Qt::black : Qt::white); + painter->drawText(cell, Qt::AlignCenter, PixelValueText(pixels[y * W + x])); + } + } + + painter->restore(); +} diff --git a/viewer/image_viewer/JFJochFollowerImage.h b/viewer/image_viewer/JFJochFollowerImage.h index 0c484f79..90bd5f5d 100644 --- a/viewer/image_viewer/JFJochFollowerImage.h +++ b/viewer/image_viewer/JFJochFollowerImage.h @@ -8,25 +8,37 @@ #include #include +#include "../../reader/JFJochReaderImage.h" + class JFJochImageItem; // A second view of a frame that a JFJochImage has already rendered: the same pixels, with its own // zoom and centre. Nothing is converted or coloured here and there is no second full-size buffer, // so following the main view costs a pointer assignment per frame rather than a whole render. // -// It shows only the image. Overlays, ROI tools and per-pixel value labels belong to the view that -// owns the data; a magnifier does not need them. +// Zoomed in far enough it writes the per-pixel counts over the image. Those are read straight from +// the detector's int32 buffer - the same source the main view colours from - so no float copy of +// the image is needed either. +// +// It draws no overlays and has no ROI tools: those belong to the view that owns the data. class JFJochFollowerImage : public QGraphicsView { Q_OBJECT + // Per-pixel counts are only legible once a detector pixel is a few tens of screen pixels + static constexpr double kLabelZoom = 30.0; + static constexpr int kMaxLabels = 2000; + JFJochImageItem *item_ = nullptr; std::shared_ptr frame_; + std::shared_ptr values_; double zoom_ = 12.0; void wheelEvent(QWheelEvent *event) override; + void drawForeground(QPainter *painter, const QRectF &rect) override; public: explicit JFJochFollowerImage(QWidget *parent = nullptr); void SetFrame(std::shared_ptr frame); + void SetPixelValues(std::shared_ptr image); void CenterAt(QPointF scenePos); }; diff --git a/viewer/windows/JFJochMagnifierWindow.cpp b/viewer/windows/JFJochMagnifierWindow.cpp index 52e6da7b..11c779eb 100644 --- a/viewer/windows/JFJochMagnifierWindow.cpp +++ b/viewer/windows/JFJochMagnifierWindow.cpp @@ -18,6 +18,10 @@ void JFJochMagnifierWindow::setFrame(std::shared_ptr frame) { m_image->SetFrame(std::move(frame)); } +void JFJochMagnifierWindow::imageLoaded(std::shared_ptr image) { + m_image->SetPixelValues(std::move(image)); +} + void JFJochMagnifierWindow::centerAt(QPointF scenePos) { if (!isVisible()) return; diff --git a/viewer/windows/JFJochMagnifierWindow.h b/viewer/windows/JFJochMagnifierWindow.h index 346be210..de01c7d6 100644 --- a/viewer/windows/JFJochMagnifierWindow.h +++ b/viewer/windows/JFJochMagnifierWindow.h @@ -23,6 +23,10 @@ class JFJochMagnifierWindow : public JFJochHelperWindow { public: explicit JFJochMagnifierWindow(QWidget *parent = nullptr); + // Raw counts for the per-pixel labels. This only stores the pointer - the pixels are + // displayed from the frame the main view rendered, nothing is converted here. + void imageLoaded(std::shared_ptr image) override; + public slots: void setFrame(std::shared_ptr frame); void centerAt(QPointF scenePos); -- 2.54.0 From 27615a8a1d30022e8c4dff6f82e2a5877f7cfe5a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:27:17 +0200 Subject: [PATCH 032/295] Viewer: label pixels from the int32 image, and paint them instead of building items Two changes to the per-pixel value labels, which appear above 30x zoom. They were up to 5000 QGraphicsSimpleTextItems created and destroyed on every overlay rebuild - so on every pan step while zoomed in. Paint them in drawForeground() instead: no item churn, no scene invalidation, and the text is laid out in viewport pixels so it is a constant readable size rather than a scene-space font scaled by 0.2. Same approach as the magnifier's labels. The value text becomes a virtual, PixelLabel(). The base still formats from image_fp, which is what the genuinely float-valued views hold (azimuthal profile, grid-scan 1/sigma^2, the calibration viewer's eight source types). JFJochDiffractionImage overrides it to read the int32 image directly: counts are exact integers, so routing them through float32 is a detour that also cannot represent summed values above 2^24 exactly. Verified at 38 wheel clicks over a module edge: identical values and gap/contrast handling to the previous float path, now centred in each pixel. Fit-view panel still pixel-identical to the pre-series baseline. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 14 ++ viewer/image_viewer/JFJochDiffractionImage.h | 3 + viewer/image_viewer/JFJochImage.cpp | 128 ++++++++---------- viewer/image_viewer/JFJochImage.h | 8 +- 4 files changed, 81 insertions(+), 72 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 5f7b3576..7c12ab74 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -1011,6 +1011,20 @@ QString JFJochDiffractionImage::HoverResolutionLabel() const { return QString("d = %1 \u00C5").arg(QString::number(hover_resolution, 'f', 2)); } +QString JFJochDiffractionImage::PixelLabel(int x, int y) const { + if (!image) + return {}; + + const int32_t v = image->Image()[static_cast(y) * W + x]; + if (v == GAP_PXL_VALUE) + return QStringLiteral("Gap"); + if (v == ERROR_PXL_VALUE) + return QStringLiteral("Err"); + if (v == SATURATED_PXL_VALUE) + return QStringLiteral("Sat"); + return QString::number(v); +} + void JFJochDiffractionImage::drawForeground(QPainter *painter, const QRectF &rect) { JFJochImage::drawForeground(painter, rect); diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index c9a3f847..3ba3884e 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -26,6 +26,9 @@ Q_OBJECT // currently sits, in viewport coordinates. QRect hover_text_rect_; [[nodiscard]] QString HoverResolutionLabel() const; + // Counts are exact integers: label them from the int32 image rather than from a float copy, + // which both avoids materialising that copy and cannot round large summed values. + [[nodiscard]] QString PixelLabel(int x, int y) const override; void drawForeground(QPainter *painter, const QRectF &rect) override; public: enum class RingMode {Auto, Estimation, Manual, None, IceRings}; diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 26172d87..3ed3b9ed 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -817,86 +817,75 @@ void JFJochImage::applyViewport(QTransform transform, QPointF center) { m_applyingViewport = false; } -void JFJochImage::writePixelLabels() { - - static QFont font([] { - QFont f("DejaVu Sans Mono"); - f.setStyleHint(QFont::TypeWriter); - f.setPixelSize(1); - return f; - }()); - static const QString kGap = QStringLiteral("Gap"); - static const QString kErr = QStringLiteral("Err"); - static const QString kSat = QStringLiteral("Sat"); - - QRectF visibleRect = mapToScene(viewport()->geometry()).boundingRect(); - - const int startX = std::max(0, static_cast(std::floor(visibleRect.left()))); - const int endX = std::min(static_cast(W), static_cast(std::ceil(visibleRect.right()))); - const int startY = std::max(0, static_cast(std::floor(visibleRect.top()))); - const int endY = std::min(static_cast(H), static_cast(std::ceil(visibleRect.bottom()))); - - const int visW = std::max(0, endX - startX); - const int visH = std::max(0, endY - startY); - int maxLabels = 5000; - +QString JFJochImage::PixelLabel(int x, int y) const { // Choose thresholds that fit your UI width constexpr float kMinFixed = 1e-3; constexpr float kMaxFixed = 1e5; - if (visW * visH <= maxLabels) { - EnsurePixelValues(); + const float val = image_fp[static_cast(y) * W + x]; + const auto absVal = std::abs(val); + const auto nearest = std::nearbyint(val); - QString numBuf; // reused buffer + if (std::isnan(val)) + return QStringLiteral("Gap"); + if (std::isinf(val)) + return std::signbit(val) ? QStringLiteral("Err") : QStringLiteral("Sat"); + if (val == 0.0f) + return QStringLiteral("0"); + if (absVal >= kMinFixed && absVal < kMaxFixed) { + if (std::abs(val - nearest) < 1e-6) + return QString::number(static_cast(val)); + if (absVal < 1e4) + return QString::number(val, 'f', label_decimals_); + return QString::number(val, 'f', std::min(label_decimals_, 2)); + } + return QString::number(val, 'e', 1); +} - for (int y = startY; y < endY; y ++) { - for (int x = startX; x < endX; x++) { - const int idx = y * W + x; - const float val = image_fp[idx]; +void JFJochImage::drawPixelLabels(QPainter *painter) { + constexpr int kMaxLabels = 5000; - const auto absVal = std::abs(val); - const auto nearest = std::nearbyint(val); + const QRectF visibleRect = mapToScene(viewport()->rect()).boundingRect(); + const int startX = std::max(0, static_cast(std::floor(visibleRect.left()))); + const int endX = std::min(static_cast(W), static_cast(std::ceil(visibleRect.right()))); + const int startY = std::max(0, static_cast(std::floor(visibleRect.top()))); + const int endY = std::min(static_cast(H), static_cast(std::ceil(visibleRect.bottom()))); + if (endX <= startX || endY <= startY) + return; + if ((endX - startX) * (endY - startY) > kMaxLabels) + return; - const QString* pText = nullptr; - if (std::isnan(val)) { - pText = &kGap; - } else if (std::isinf(val)) { - pText = std::signbit(val) ? &kErr : &kSat; - } else if (val == 0.0f) { - numBuf = QStringLiteral("0"); - pText = &numBuf; - } else if (absVal >= kMinFixed && absVal < kMaxFixed) { - if (std::abs(val - nearest) < 1e-6) - numBuf = QString::number(static_cast(val)); - else if (absVal < 1e4) - numBuf = QString::number(val, 'f', label_decimals_); - else - numBuf = QString::number(val, 'f', std::min(label_decimals_, 2)); - pText = &numBuf; - } else { - numBuf = QString::number(val, 'e', 1); - pText = &numBuf; - } + // Laid out in viewport pixels: a constant, readable size independent of the zoom + painter->save(); + painter->resetTransform(); - auto *textItem = new QGraphicsSimpleTextItem(*pText); - textItem->setFont(font); - // Read the colour back from the rendered image rather than keeping a - // full-size mirror of it around for the few pixels that get a label. - const QRgb pxl = frame_->pixel(x, y); - if (luminance(rgb{.r = static_cast(qRed(pxl)), - .g = static_cast(qGreen(pxl)), - .b = static_cast(qBlue(pxl))}) > 128.0) - textItem->setBrush(Qt::black); - else - textItem->setBrush(Qt::white); + QFont font("DejaVu Sans Mono"); + font.setStyleHint(QFont::TypeWriter); + font.setPixelSize(std::clamp(static_cast(scale_factor * 0.3), 7, 16)); + painter->setFont(font); - textItem->setPos(x + 0.3, y + 0.2); - textItem->setTransform(QTransform::fromScale(0.2, 0.2)); - scene()->addItem(textItem); - addOverlayItem(textItem); - } + for (int y = startY; y < endY; y++) { + for (int x = startX; x < endX; x++) { + const QRect cell = mapFromScene(QRectF(x, y, 1, 1)).boundingRect(); + // Read the colour back from the rendered image rather than keeping a full-size + // mirror of it around for the few pixels that get a label. + const QRgb pxl = frame_->pixel(x, y); + painter->setPen(luminance(rgb{.r = static_cast(qRed(pxl)), + .g = static_cast(qGreen(pxl)), + .b = static_cast(qBlue(pxl))}) > 128.0 + ? Qt::black : Qt::white); + painter->drawText(cell, Qt::AlignCenter, PixelLabel(x, y)); } } + + painter->restore(); +} + +void JFJochImage::drawForeground(QPainter *painter, const QRectF &rect) { + QGraphicsView::drawForeground(painter, rect); + + if (scale_factor > 30.0 && W * H > 0 && frame_ && !frame_->isNull()) + drawPixelLabels(painter); } void JFJochImage::resetScenePointers() { @@ -928,9 +917,6 @@ void JFJochImage::updateOverlay() { image_dirty_ = false; } - if (scale_factor > 30.0) - writePixelLabels(); - DrawROI(); addCustomOverlay(); diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index b0b1da6d..acc6ea09 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -82,7 +82,7 @@ class JFJochImage : public QGraphicsView { void DrawROI(); virtual void addCustomOverlay(); void updateROI(); - void writePixelLabels(); + void drawPixelLabels(QPainter *painter); void wheelEvent(QWheelEvent* event) override; void resizeEvent(QResizeEvent *event) override; void contextMenuEvent(QContextMenuEvent *event) override; @@ -156,6 +156,12 @@ protected: QPointF roiEndPos; QRectF roiBox; + // Text for the per-pixel value label at (x, y). The base reads image_fp, which is what the + // float-valued views hold; a view whose source is exact integers overrides this so the label + // is exact and no float copy of the image has to exist at all. + [[nodiscard]] virtual QString PixelLabel(int x, int y) const; + void drawForeground(QPainter *painter, const QRectF &rect) override; + static QPointF RoundPoint(const QPointF& p); virtual void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) = 0; -- 2.54.0 From 57f9e4238267d20a549123b959b7ac9cc49c6215 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:32:47 +0200 Subject: [PATCH 033/295] Viewer: the region of interest belongs to the diffraction view alone Drawing an ROI only means something where there are detector counts to accumulate. Gate the gesture on a virtual AllowROI(), true only for JFJochDiffractionImage: shift-drag, the resize handles, the hover cursor and the "Clear ROI" context entry now do nothing in the azimuthal, grid-scan and calibration views, which cannot report anything about a box anyway. The statistics move out of the base class into the diffraction view and read the int32 image directly, so no float copy of the detector image is built for them either. With the labels already converted, image_fp is now untouched by the diffraction view, and the lazy EnsurePixelValues machinery it needed is gone. image_fp stays as the base's representation for the views whose data really is float: the azimuthal profile, the grid-scan 1/sigma^2 map, and the calibration viewer's eight source types. Removed with it: the ROI readouts in the calibration and 2D azimuthal windows, which were the only two consumers of roiCalculated -- the diffraction view emitted it and nothing listened. Nothing surfaces ROI statistics now; the pixel-mask case wants rectangles counting excluded pixels and deserves its own design. JFJochViewerROIResult is still used by the side-panel ROI list, so the widget stays. Verified in the GUI: shift-drag in the diffraction view still draws the box, turns it into a named ROI and runs the statistics; fit-view panel remains pixel-identical to the pre-series baseline. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/image_viewer/JFJochAzIntImage.cpp | 2 - .../image_viewer/JFJochDiffractionImage.cpp | 112 +++++++++++++----- viewer/image_viewer/JFJochDiffractionImage.h | 15 ++- viewer/image_viewer/JFJochGridScanImage.cpp | 2 - viewer/image_viewer/JFJochImage.cpp | 108 ++--------------- viewer/image_viewer/JFJochImage.h | 13 +- viewer/image_viewer/JFJochSimpleImage.cpp | 2 - viewer/windows/JFJoch2DAzintImageWindow.cpp | 6 - viewer/windows/JFJochCalibrationWindow.cpp | 6 - 9 files changed, 111 insertions(+), 155 deletions(-) diff --git a/viewer/image_viewer/JFJochAzIntImage.cpp b/viewer/image_viewer/JFJochAzIntImage.cpp index a9905f24..7ccf3ce4 100644 --- a/viewer/image_viewer/JFJochAzIntImage.cpp +++ b/viewer/image_viewer/JFJochAzIntImage.cpp @@ -23,7 +23,6 @@ void JFJochAzIntImage::Clear() { void JFJochAzIntImage::imageLoaded(std::shared_ptr in_image) { if (!in_image) { Clear(); - CalcROI(); return; } @@ -71,7 +70,6 @@ void JFJochAzIntImage::imageLoaded(std::shared_ptr in_i // Render the image and redraw using base class functionality RenderImage(); Redraw(); - CalcROI(); } else { Clear(); } diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 7c12ab74..45b6d56e 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -127,7 +127,6 @@ void JFJochDiffractionImage::LoadImageInternal() { W = image->Dataset().experiment.GetXPixelsNum(); H = image->Dataset().experiment.GetYPixelsNum(); - pixel_values_valid_ = false; // image_fp is filled on demand, see EnsurePixelValues } void JFJochDiffractionImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const { @@ -150,34 +149,6 @@ void JFJochDiffractionImage::ColorRow(size_t y, const PixelColorMap &map, QRgb * } } -void JFJochDiffractionImage::EnsurePixelValues() { - if (pixel_values_valid_ || !image) - return; - - const auto &img = image->Image(); - image_fp.resize(W*H); - - QVector rows; - rows.reserve(H); - for (int y = 0; y < H; ++y) rows.push_back(y); - - // Fill the float image with pixel data from the array - QtConcurrent::blockingMap(rows, [&](int y) { - for (size_t pxl = y * W; pxl < (y + 1) * W; pxl++) { - auto val = img[pxl]; - if (val == GAP_PXL_VALUE) - image_fp[pxl] = NAN; - else if (val == ERROR_PXL_VALUE) - image_fp[pxl] = -INFINITY; - else if (val == SATURATED_PXL_VALUE) - image_fp[pxl] = INFINITY; - else - image_fp[pxl] = static_cast(val); - } - }); - - pixel_values_valid_ = true; -} void JFJochDiffractionImage::DrawSpots() { // Compute current visible area in scene coordinates @@ -1011,6 +982,89 @@ QString JFJochDiffractionImage::HoverResolutionLabel() const { return QString("d = %1 \u00C5").arg(QString::number(hover_resolution, 'f', 2)); } +ROIMessage JFJochDiffractionImage::AccumulateROI( + int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax, + const std::function &inside) const { + int64_t roi_val = 0; + uint64_t roi_val_2 = 0; + int64_t roi_max = INT64_MIN; + uint64_t roi_npixel = 0; + uint64_t roi_npixel_masked = 0; + float x_weighted = 0.0f; + float y_weighted = 0.0f; + + // Clamp bounds defensively to the image + xmin = std::max(0, xmin); + ymin = std::max(0, ymin); + xmax = std::min(W, xmax); + ymax = std::min(H, ymax); + + const auto &pixels = image->Image(); + + for (int64_t y = ymin; y < ymax; ++y) { + for (int64_t x = xmin; x < xmax; ++x) { + if (!inside(x, y)) continue; + + const int32_t val = pixels[x + W * y]; + + if (val == SATURATED_PXL_VALUE || val == ERROR_PXL_VALUE) { + roi_npixel_masked++; + } else if (val != GAP_PXL_VALUE) { + x_weighted += static_cast(val) * x; + y_weighted += static_cast(val) * y; + roi_val += val; + roi_val_2 += static_cast(val) * val; + if (val > roi_max) roi_max = val; + roi_npixel++; + } + } + } + + return ROIMessage{ + .sum = roi_val, + .sum_square = roi_val_2, + .max_count = roi_max, + .pixels = roi_npixel, + .pixels_masked = roi_npixel_masked, + .x_weighted = std::lroundf(x_weighted), + .y_weighted = std::lroundf(y_weighted), + }; +} + +void JFJochDiffractionImage::CalcROI() { + if (!image || W * H == 0) { + auto msg = ROIMessage{.pixels = 0, .pixels_masked = 0}; + emit roiCalculated(msg); + return; + } + + auto box_norm = roiBox.normalized(); + + // Using the rectangle as-is; you can adjust inclusivity if needed + const int64_t xmin = box_norm.left(); + const int64_t xmax = box_norm.right(); + const int64_t ymin = box_norm.top(); + const int64_t ymax = box_norm.bottom(); + + ROIMessage msg{}; + if (roi_type == RoiType::RoiBox) + msg = AccumulateROI(xmin, xmax, ymin, ymax, + [](int64_t, int64_t) { return true; }); // everything in the rectangle + else { + const QPointF delta = roiStartPos - roiEndPos; + const float cx = static_cast(roiStartPos.x()); + const float cy = static_cast(roiStartPos.y()); + const float r2 = static_cast(delta.x() * delta.x() + delta.y() * delta.y()); + msg = AccumulateROI(xmin, xmax, ymin, ymax, + [cx, cy, r2](int64_t x, int64_t y) { + const float dx = static_cast(x) - cx; + const float dy = static_cast(y) - cy; + return dx * dx + dy * dy <= r2; + }); + } + emit roiCalculated(msg); +} + QString JFJochDiffractionImage::PixelLabel(int x, int y) const { if (!image) return {}; diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 3ba3884e..4d717001 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -3,6 +3,8 @@ #pragma once +#include + #include #include "JFJochImage.h" @@ -29,6 +31,12 @@ Q_OBJECT // Counts are exact integers: label them from the int32 image rather than from a float copy, // which both avoids materialising that copy and cannot round large summed values. [[nodiscard]] QString PixelLabel(int x, int y) const override; + + // This is the view that has detector counts, so it is the one that offers an ROI + [[nodiscard]] bool AllowROI() const override { return true; } + void CalcROI() override; + [[nodiscard]] ROIMessage AccumulateROI(int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax, + const std::function &inside) const; void drawForeground(QPainter *painter, const QRectF &rect) override; public: enum class RingMode {Auto, Estimation, Manual, None, IceRings}; @@ -39,11 +47,9 @@ private: void addCustomOverlay() override; void LoadImageInternal(); - // Colour straight from the int32 detector image; image_fp is only materialised when the - // base class actually needs pixel values (ROI statistics, per-pixel labels). + // Colour straight from the int32 detector image; no float copy of it is ever built void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const override; - void EnsurePixelValues() override; - bool pixel_values_valid_ = false; + void DrawResolutionRings(); void DrawROIs(); void DrawAzimuthalROI(const ROIAzimuthal &az, const QColor &color, const DiffractionGeometry &geom); @@ -113,6 +119,7 @@ private: void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; signals: + void roiCalculated(ROIMessage &output); void roiGeometryEdited(ROIDefinition rois); void roiSelected(QString name); // user picked an ROI by clicking it on the image public slots: diff --git a/viewer/image_viewer/JFJochGridScanImage.cpp b/viewer/image_viewer/JFJochGridScanImage.cpp index 7bb761fd..c4bfc1bd 100644 --- a/viewer/image_viewer/JFJochGridScanImage.cpp +++ b/viewer/image_viewer/JFJochGridScanImage.cpp @@ -14,7 +14,6 @@ void JFJochGridScanImage::clear() { if (scene()) scene()->clear(); resetScenePointers(); - CalcROI(); } void JFJochGridScanImage::loadData(const std::vector &data, const GridScanSettings &settings, bool in_one_over_d2) { @@ -60,7 +59,6 @@ void JFJochGridScanImage::loadData(const std::vector &data, const GridSca RenderImage(); Redraw(); - CalcROI(); } void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers modifiers) { diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 3ed3b9ed..47dc7a04 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -238,18 +238,20 @@ void JFJochImage::mousePressEvent(QMouseEvent *event) { return; } - active_handle_ = hitTestROIHandle(scenePos, 4.0 / std::sqrt(std::max(1e-4, scale_factor))); + active_handle_ = AllowROI() + ? hitTestROIHandle(scenePos, 4.0 / std::sqrt(std::max(1e-4, scale_factor))) + : ResizeHandle::None; if (active_handle_ != ResizeHandle::None && active_handle_ != ResizeHandle::Inside) { mouse_event_type = MouseEventType::ResizingROI; roiStartPos = roiBox.topLeft(); roiEndPos = roiBox.bottomRight(); setCursor(Qt::SizeAllCursor); - } else if (roiBox.contains(scenePos)) { + } else if (AllowROI() && roiBox.contains(scenePos)) { mouse_event_type = MouseEventType::MovingROI; lastMousePos = event->pos(); setCursor(Qt::ClosedHandCursor); - } else if (event->modifiers() & Qt::Modifier::SHIFT) { + } else if (AllowROI() && (event->modifiers() & Qt::Modifier::SHIFT)) { mouse_event_type = MouseEventType::DrawingROI; roiStartPos = RoundPoint(scenePos); roiEndPos = roiStartPos; @@ -333,6 +335,8 @@ void JFJochImage::mouseMoveEvent(QMouseEvent *event) { break; } case MouseEventType::None: { + if (!AllowROI()) + break; const qreal tol = 4.0 / std::sqrt(std::max(1e-4, scale_factor)); ResizeHandle h = hitTestROIHandle(scenePos, tol); // Update hover state so overlay can draw arrows/handles accordingly @@ -400,7 +404,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) { QAction *saveWithOverlayAct = menu.addAction(tr("Save image with overlay as JPEG...")); menu.addSeparator(); QAction *fitAct = menu.addAction(tr("Fit image to view")); - QAction *clearRoiAct = menu.addAction(tr("Clear ROI")); + QAction *clearRoiAct = AllowROI() ? menu.addAction(tr("Clear ROI")) : nullptr; const bool hasImage = (W > 0 && H > 0 && !frame_->isNull()); copyImageAct->setEnabled(hasImage); @@ -421,7 +425,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) { saveImageToFile(true); } else if (chosen == fitAct) { fitToView(); - } else if (chosen == clearRoiAct) { + } else if (clearRoiAct && chosen == clearRoiAct) { clearROIInternal(); } } @@ -607,6 +611,8 @@ void JFJochImage::addOverlayItem(QGraphicsItem *item) { } void JFJochImage::DrawROI() { + if (!AllowROI()) + return; if (roiBox.isNull() || roiBox.width() <= 0 || roiBox.height() <= 0) return; auto scn = scene(); @@ -924,99 +930,7 @@ void JFJochImage::updateOverlay() { void JFJochImage::addCustomOverlay() {} -ROIMessage JFJochImage::accumulateROI( - int64_t xmin, int64_t xmax, - int64_t ymin, int64_t ymax, - const std::function &inside) { - int64_t roi_val = 0; - uint64_t roi_val_2 = 0; - int64_t roi_max = INT64_MIN; - uint64_t roi_npixel = 0; - uint64_t roi_npixel_masked = 0; - float x_weighted = 0.0f; - float y_weighted = 0.0f; - // Clamp bounds defensively to the image - xmin = std::max(0, xmin); - ymin = std::max(0, ymin); - xmax = std::min(W, xmax); - ymax = std::min(H, ymax); - - for (int64_t y = ymin; y < ymax; ++y) { - for (int64_t x = xmin; x < xmax; ++x) { - if (!inside(x, y)) continue; - - float val = image_fp[x + W * y]; - - if (std::isinf(val)) { - roi_npixel_masked++; - } else if (std::isfinite(val)) { - x_weighted += val * x; - y_weighted += val * y; - roi_val += val; - roi_val_2 += val * val; - if (val > roi_max) roi_max = val; - roi_npixel++; - } - } - } - - return ROIMessage{ - .sum = roi_val, - .sum_square = roi_val_2, - .max_count = roi_max, - .pixels = roi_npixel, - .pixels_masked = roi_npixel_masked, - .x_weighted = std::lroundf(x_weighted), - .y_weighted = std::lroundf(y_weighted), - }; -} - -void JFJochImage::CalcROI() { - if (W*H == 0) { - auto msg = ROIMessage{ - .pixels = 0, - .pixels_masked = 0}; - emit roiCalculated(msg); - return; - } - - auto box_norm = roiBox.normalized(); - - // accumulateROI only reads pixel values inside the box, so an empty ROI needs none - if (box_norm.width() > 0 && box_norm.height() > 0) - EnsurePixelValues(); - - // Using the rectangle as-is; you can adjust inclusivity if needed - int64_t xmin = box_norm.left(); - int64_t xmax = box_norm.right(); - int64_t ymin = box_norm.top(); - int64_t ymax = box_norm.bottom(); - - ROIMessage msg{}; - if (roi_type == RoiType::RoiBox) - msg = accumulateROI( - xmin, xmax, ymin, ymax, - [](int64_t, int64_t) { return true; } // everything in the rectangle - ); - else { - QPointF delta = roiStartPos - roiEndPos; - double radius2 = delta.x() * delta.x() + delta.y() * delta.y(); - const float cx = static_cast(roiStartPos.x()); - const float cy = static_cast(roiStartPos.y()); - const float r2 = static_cast(radius2); - msg = accumulateROI( - xmin, xmax, ymin, ymax, - [cx, cy, r2](int64_t x, int64_t y) { - const float dx = static_cast(x) - cx; - const float dy = static_cast(y) - cy; - const float dist2 = dx * dx + dy * dy; - return dist2 <= r2; - } - ); - } - emit roiCalculated(msg); -} void JFJochImage::fitToView() { initial_fit_done_ = false; diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index acc6ea09..ef83e2f6 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -91,9 +91,10 @@ class JFJochImage : public QGraphicsView { void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - ROIMessage accumulateROI(int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax, - const std::function& inside); protected: + // Only the view that owns detector counts offers a region of interest; for the others a + // shift-drag would draw a box that means nothing. + [[nodiscard]] virtual bool AllowROI() const { return false; } virtual void beforeOverlayCleared(); bool show_saturation = false; @@ -184,6 +185,9 @@ protected: ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const; + // Statistics over roiBox, for the view that has pixel values to accumulate + virtual void CalcROI() {} + void updateOverlay(); void RenderImage(); PixelColorMap MakeColorMap() const; @@ -191,9 +195,6 @@ protected: // maps image_fp; a subclass whose source is already a compact buffer can map that directly // and skip materialising the float image. virtual void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const; - // Fill image_fp, which the base class reads for ROI statistics and per-pixel value labels. - // Subclasses that colour without it fill it on demand here rather than on every frame. - virtual void EnsurePixelValues() {} // Re-render once the event queue drains. The foreground slider and the wheel emit far // faster than a large image can be recoloured, so intermediate values are dropped // instead of queueing a full recolour per event. @@ -205,7 +206,6 @@ protected: // once per scrollbar; the gesture rebuilds it once itself. bool suppress_overlay_update_ = false; void Redraw(); - void CalcROI(); // Invalidate image_item_ and overlay tracking after scene()->clear() void resetScenePointers(); @@ -228,7 +228,6 @@ signals: void writeStatusBar(QString string, int timeout_ms = 0); void roiBoxUpdated(QRect box); void roiCircleUpdated(double x, double y, double radius); - void roiCalculated(ROIMessage &output); void viewportChanged(QTransform transform, QPointF center); void hoverScenePos(QPointF scenePos); // A new frame has been rendered into Frame(). Follower views repaint on this. diff --git a/viewer/image_viewer/JFJochSimpleImage.cpp b/viewer/image_viewer/JFJochSimpleImage.cpp index 5e561cfa..2b899f9a 100644 --- a/viewer/image_viewer/JFJochSimpleImage.cpp +++ b/viewer/image_viewer/JFJochSimpleImage.cpp @@ -30,14 +30,12 @@ void JFJochSimpleImage::setImage(std::shared_ptr img) { loadImageInternal(); RenderImage(); Redraw(); - CalcROI(); } else { image_.reset(); W = 0; H = 0; if (scene()) scene()->clear(); resetScenePointers(); - CalcROI(); } } diff --git a/viewer/windows/JFJoch2DAzintImageWindow.cpp b/viewer/windows/JFJoch2DAzintImageWindow.cpp index 062328a1..287fafc4 100644 --- a/viewer/windows/JFJoch2DAzintImageWindow.cpp +++ b/viewer/windows/JFJoch2DAzintImageWindow.cpp @@ -3,7 +3,6 @@ #include #include "JFJoch2DAzintImageWindow.h" -#include "../widgets/JFJochViewerROIResult.h" JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelperWindow(parent) { QWidget *centralWidget = new QWidget(this); @@ -25,12 +24,9 @@ JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelp foreground_row->addWidget(new QLabel("Foreground:")); foreground_row->addWidget(foreground_slider); - auto roi_result = new JFJochViewerROIResult(this); - grid_layout->addLayout(background_row, 0, 0, 1, 2); grid_layout->addLayout(foreground_row, 1, 0, 1, 2); grid_layout->addWidget(viewer, 2, 0, 1, 2); - grid_layout->addWidget(roi_result, 3, 0, 1, 2); centralWidget->setLayout(grid_layout); connect(viewer, &JFJochAzIntImage::backgroundChanged, @@ -45,8 +41,6 @@ JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelp foreground_slider->setValue(val); }); - connect(viewer, &JFJochAzIntImage::roiCalculated, roi_result, &JFJochViewerROIResult::SetROIResult); - connect(background_slider, &SliderPlusBox::valueChanged, viewer, &JFJochAzIntImage::changeBackground); connect(foreground_slider, &SliderPlusBox::valueChanged, viewer, &JFJochAzIntImage::changeForeground); diff --git a/viewer/windows/JFJochCalibrationWindow.cpp b/viewer/windows/JFJochCalibrationWindow.cpp index 05ffc281..2033f45d 100644 --- a/viewer/windows/JFJochCalibrationWindow.cpp +++ b/viewer/windows/JFJochCalibrationWindow.cpp @@ -7,7 +7,6 @@ #include #include -#include "../widgets/JFJochViewerROIResult.h" JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelperWindow(parent) { QWidget *centralWidget = new QWidget(this); @@ -37,14 +36,11 @@ JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelper foreground_row->addWidget(new QLabel("Foreground:")); foreground_row->addWidget(foreground_slider); - auto roi_result = new JFJochViewerROIResult(this); - grid_layout->addWidget(calibration_option, 0, 0); grid_layout->addWidget(color_map_select, 0, 1); grid_layout->addLayout(background_row, 1, 0, 1, 2); grid_layout->addLayout(foreground_row, 2, 0, 1, 2); grid_layout->addWidget(viewer, 3, 0, 1, 2); - grid_layout->addWidget(roi_result, 4, 0, 1, 2); connect(viewer, &JFJochSimpleImage::backgroundChanged, [this] (float val) { @@ -57,8 +53,6 @@ JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelper QSignalBlocker blocker(foreground_slider); foreground_slider->setValue(val); }); - connect(viewer, &JFJochSimpleImage::roiCalculated, roi_result, &JFJochViewerROIResult::SetROIResult); - connect(background_slider, &SliderPlusBox::valueChanged, viewer, &JFJochSimpleImage::changeBackground); connect(foreground_slider, &SliderPlusBox::valueChanged, viewer, &JFJochSimpleImage::changeForeground); -- 2.54.0 From 84a1538495b701e750b25e0436879f17fc68e3e3 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:42:30 +0200 Subject: [PATCH 034/295] Viewer: drop the in-view ROI accumulation, the worker already does it The statistics shown for a drawn ROI do not come from the view at all. Drawing one promotes it to a named ROI, roiGeometryEdited goes to the reading worker, and the worker's per-image results arrive in ImageData().roi, which is what the Inspector's ROI section displays. So accumulateROI/CalcROI/roiCalculated were a second implementation of the same thing whose output nothing read -- and the worker's version is the better one: it handles the mask and it persists per image. Remove them. The view now owns only the ROI's geometry and gestures, which is all the worker needs from it. This corrects the previous commit's claim that nothing surfaces ROI statistics: the Inspector does, via the worker. Verified by drawing a box over the beam centre: Sum 65453, Max 1634, Mean 0.468, centre of mass (786.3, 843.4) against a beam centre of (764, 850). Note the numbers appear from the next analysed frame onward, since the worker attaches them at analysis time and the displayed frame was analysed before the ROI existed -- that behaviour is unchanged here. Co-Authored-By: Claude Opus 5 (1M context) --- .../image_viewer/JFJochDiffractionImage.cpp | 84 ------------------- viewer/image_viewer/JFJochDiffractionImage.h | 7 +- viewer/image_viewer/JFJochImage.cpp | 2 - viewer/image_viewer/JFJochImage.h | 3 - 4 files changed, 1 insertion(+), 95 deletions(-) diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 45b6d56e..267415c5 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -877,7 +877,6 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr LoadImageInternal(); RenderImage(); Redraw(); - CalcROI(); } else { image.reset(); W = 0; H = 0; @@ -887,8 +886,6 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr resetScenePointers(); hover_resolution = NAN; DrawResolutionText(); - - CalcROI(); } } @@ -982,88 +979,7 @@ QString JFJochDiffractionImage::HoverResolutionLabel() const { return QString("d = %1 \u00C5").arg(QString::number(hover_resolution, 'f', 2)); } -ROIMessage JFJochDiffractionImage::AccumulateROI( - int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax, - const std::function &inside) const { - int64_t roi_val = 0; - uint64_t roi_val_2 = 0; - int64_t roi_max = INT64_MIN; - uint64_t roi_npixel = 0; - uint64_t roi_npixel_masked = 0; - float x_weighted = 0.0f; - float y_weighted = 0.0f; - // Clamp bounds defensively to the image - xmin = std::max(0, xmin); - ymin = std::max(0, ymin); - xmax = std::min(W, xmax); - ymax = std::min(H, ymax); - - const auto &pixels = image->Image(); - - for (int64_t y = ymin; y < ymax; ++y) { - for (int64_t x = xmin; x < xmax; ++x) { - if (!inside(x, y)) continue; - - const int32_t val = pixels[x + W * y]; - - if (val == SATURATED_PXL_VALUE || val == ERROR_PXL_VALUE) { - roi_npixel_masked++; - } else if (val != GAP_PXL_VALUE) { - x_weighted += static_cast(val) * x; - y_weighted += static_cast(val) * y; - roi_val += val; - roi_val_2 += static_cast(val) * val; - if (val > roi_max) roi_max = val; - roi_npixel++; - } - } - } - - return ROIMessage{ - .sum = roi_val, - .sum_square = roi_val_2, - .max_count = roi_max, - .pixels = roi_npixel, - .pixels_masked = roi_npixel_masked, - .x_weighted = std::lroundf(x_weighted), - .y_weighted = std::lroundf(y_weighted), - }; -} - -void JFJochDiffractionImage::CalcROI() { - if (!image || W * H == 0) { - auto msg = ROIMessage{.pixels = 0, .pixels_masked = 0}; - emit roiCalculated(msg); - return; - } - - auto box_norm = roiBox.normalized(); - - // Using the rectangle as-is; you can adjust inclusivity if needed - const int64_t xmin = box_norm.left(); - const int64_t xmax = box_norm.right(); - const int64_t ymin = box_norm.top(); - const int64_t ymax = box_norm.bottom(); - - ROIMessage msg{}; - if (roi_type == RoiType::RoiBox) - msg = AccumulateROI(xmin, xmax, ymin, ymax, - [](int64_t, int64_t) { return true; }); // everything in the rectangle - else { - const QPointF delta = roiStartPos - roiEndPos; - const float cx = static_cast(roiStartPos.x()); - const float cy = static_cast(roiStartPos.y()); - const float r2 = static_cast(delta.x() * delta.x() + delta.y() * delta.y()); - msg = AccumulateROI(xmin, xmax, ymin, ymax, - [cx, cy, r2](int64_t x, int64_t y) { - const float dx = static_cast(x) - cx; - const float dy = static_cast(y) - cy; - return dx * dx + dy * dy <= r2; - }); - } - emit roiCalculated(msg); -} QString JFJochDiffractionImage::PixelLabel(int x, int y) const { if (!image) diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 4d717001..f3998be1 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -3,8 +3,6 @@ #pragma once -#include - #include #include "JFJochImage.h" @@ -34,9 +32,7 @@ Q_OBJECT // This is the view that has detector counts, so it is the one that offers an ROI [[nodiscard]] bool AllowROI() const override { return true; } - void CalcROI() override; - [[nodiscard]] ROIMessage AccumulateROI(int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax, - const std::function &inside) const; + void drawForeground(QPainter *painter, const QRectF &rect) override; public: enum class RingMode {Auto, Estimation, Manual, None, IceRings}; @@ -119,7 +115,6 @@ private: void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; signals: - void roiCalculated(ROIMessage &output); void roiGeometryEdited(ROIDefinition rois); void roiSelected(QString name); // user picked an ROI by clicking it on the image public slots: diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 47dc7a04..9fb40eba 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -514,7 +514,6 @@ void JFJochImage::saveImageToFile(bool with_overlay) { void JFJochImage::clearROIInternal() { roiBox = QRectF(); // clear any ROI // Keep current roi_type; ROI simply becomes empty - CalcROI(); // will emit a zeroed ROI message updateOverlay(); emit writeStatusBar(tr("ROI cleared"), 1500); } @@ -602,7 +601,6 @@ void JFJochImage::updateROI() { } emit roiCircleUpdated(roiStartPos.x(), roiStartPos.y(), radius); } - CalcROI(); updateOverlay(); } diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index ef83e2f6..23f8ddba 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -185,9 +185,6 @@ protected: ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const; - // Statistics over roiBox, for the view that has pixel values to accumulate - virtual void CalcROI() {} - void updateOverlay(); void RenderImage(); PixelColorMap MakeColorMap() const; -- 2.54.0 From 79b86164a44b45c30779d7115365fe62a08d7fcc Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 21:49:02 +0200 Subject: [PATCH 035/295] Viewer: ROI statistics appear as soon as the first ROI is drawn Drawing an ROI reported Sum 0 and Max 0 until the next frame was loaded, with only the pixel count looking right because that comes from the ROI map rather than from the data. SetROIDefinition_i called RunROIOnly, which integrates whatever the preprocessor buffer already holds. LoadImage_i only preprocesses an image when a ROI is already defined, so the very first ROI is drawn on an image that was never preprocessed: the buffer is empty and every sum integrates to zero. From the next frame on a ROI exists, LoadImage_i preprocesses, and the numbers look correct -- which is what made this look like a refresh problem rather than a wrong call. Use AnalyzeROIOnly, which preprocesses the image before integrating. It costs a pass over the image per ROI edit, of the same order as one recolour, and ROI edits already keep at most one recompute in flight (live_pending_ in JFJochDiffractionImage), so the editing rate is bounded. I did not measure the drag rate specifically. Verified with a single frame loaded and no frame step: the first ROI drawn over the beam centre reports Sum 66065, Max 2761, Mean 0.473, centre of mass (787.3, 844.5). The Max equals the image's own reported maximum of 2761, as it must for a box containing the brightest pixel. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/JFJochImageReadingWorker.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index 88471c1d..51907f29 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -761,7 +761,12 @@ void JFJochImageReadingWorker::SetROIDefinition_i(const ROIDefinition &rois) { if (image_analysis) { try { - image_analysis->RunROIOnly(current_image_ptr->ImageData()); + // AnalyzeROIOnly, not RunROIOnly: the latter integrates whatever the preprocessor + // buffer already holds, and an image that carried no ROI when it loaded was never + // preprocessed at all (see LoadImage_i), so every sum would come back zero. That is + // exactly the case here - the user has just drawn the first ROI. Preprocessing the + // image again costs a pass over it, which is affordable at ROI-editing rates. + image_analysis->AnalyzeROIOnly(current_image_ptr->ImageData()); } catch (const std::exception &e) { logger.Error("ROI-only analysis failed: {}", e.what()); } -- 2.54.0 From 6f15ae04b7e3f029e269b2cb228091ee129ff891 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 22:04:54 +0200 Subject: [PATCH 036/295] Viewer: read the data-analysis algorithm documentation from Help Adds Help > Data Analysis Algorithms, showing docs/CPU_DATA_ANALYSIS.md in a window. The document is baked into the binary through the Qt resource system (aliased to :/cpu_data_analysis.md), so it needs no docs/ directory at runtime and cannot drift from the build it shipped with. Same shape as the existing third-party licences window, created once and raised thereafter. Limitation worth knowing: QTextBrowser::setMarkdown renders the headings, lists, emphasis and inline code well, but it has no math support, so the inline LaTeX in the more quantitative sections appears as raw "$...$" source. The descriptive material - which is most of the 744 lines - reads fine. Fixing that properly means either pre-rendering the document to HTML with a math filter at build time, or sending the user to the Read The Docs copy instead; neither seemed worth doing without knowing which you would prefer. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/CMakeLists.txt | 2 ++ viewer/JFJochViewerMenu.cpp | 13 ++++++++++++ viewer/JFJochViewerMenu.h | 3 +++ viewer/resources/resources.qrc | 3 +++ viewer/windows/JFJochAlgorithmDocWindow.cpp | 23 +++++++++++++++++++++ viewer/windows/JFJochAlgorithmDocWindow.h | 15 ++++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 viewer/windows/JFJochAlgorithmDocWindow.cpp create mode 100644 viewer/windows/JFJochAlgorithmDocWindow.h diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 9c311a5b..0ef08999 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -87,6 +87,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView windows/JFJochHelperWindow.h windows/JFJochLicenseWindow.cpp windows/JFJochLicenseWindow.h + windows/JFJochAlgorithmDocWindow.cpp + windows/JFJochAlgorithmDocWindow.h image_viewer/JFJochImage.cpp image_viewer/JFJochImage.h image_viewer/JFJochFollowerImage.cpp diff --git a/viewer/JFJochViewerMenu.cpp b/viewer/JFJochViewerMenu.cpp index 8d039375..5719308c 100644 --- a/viewer/JFJochViewerMenu.cpp +++ b/viewer/JFJochViewerMenu.cpp @@ -17,6 +17,7 @@ #include #include "JFJochViewerWindow.h" +#include "windows/JFJochAlgorithmDocWindow.h" #include "windows/JFJochLicenseWindow.h" #include "../common/GitInfo.h" #include "../common/CUDAWrapper.h" @@ -81,6 +82,9 @@ JFJochViewerMenu::JFJochViewerMenu(QWidget *parent) : QMenuBar(parent) { const QAction *aboutAction = helpMenu->addAction("About"); connect(aboutAction, &QAction::triggered, this, &JFJochViewerMenu::aboutSelected); + const QAction *algorithmDocAction = helpMenu->addAction("Data Analysis Algorithms"); + connect(algorithmDocAction, &QAction::triggered, this, &JFJochViewerMenu::algorithmDocSelected); + const QAction *licensesAction = helpMenu->addAction("Third-party Licenses"); connect(licensesAction, &QAction::triggered, this, &JFJochViewerMenu::licensesSelected); } @@ -148,6 +152,15 @@ void JFJochViewerMenu::licensesSelected() { licenseWindow->activateWindow(); } +void JFJochViewerMenu::algorithmDocSelected() { + // Same pattern as the licence window: created once, then raised. + if (algorithmDocWindow == nullptr) + algorithmDocWindow = new JFJochAlgorithmDocWindow(window()); + algorithmDocWindow->show(); + algorithmDocWindow->raise(); + algorithmDocWindow->activateWindow(); +} + void JFJochViewerMenu::openSelected() { QString fileName = QFileDialog::getOpenFileName( this, diff --git a/viewer/JFJochViewerMenu.h b/viewer/JFJochViewerMenu.h index 50f49ef0..45eeea3b 100644 --- a/viewer/JFJochViewerMenu.h +++ b/viewer/JFJochViewerMenu.h @@ -7,6 +7,7 @@ #include "windows/JFJochHelperWindow.h" +class JFJochAlgorithmDocWindow; class JFJochLicenseWindow; class QDockWidget; @@ -21,6 +22,7 @@ class JFJochViewerMenu : public QMenuBar { QMenu *windowMenu = nullptr; JFJochLicenseWindow *licenseWindow = nullptr; + JFJochAlgorithmDocWindow *algorithmDocWindow = nullptr; public: explicit JFJochViewerMenu(QWidget *parent = nullptr); ~JFJochViewerMenu() override = default; @@ -50,6 +52,7 @@ signals: private slots: void aboutSelected(); void licensesSelected(); + void algorithmDocSelected(); void quitSelected(); void closeSelected(); diff --git a/viewer/resources/resources.qrc b/viewer/resources/resources.qrc index 6ec0fb4b..3eaa996e 100644 --- a/viewer/resources/resources.qrc +++ b/viewer/resources/resources.qrc @@ -7,6 +7,9 @@ jfjoch.png third_party_licenses.html + + ../../docs/CPU_DATA_ANALYSIS.md psi_01.png psi_02.png psi_03.png diff --git a/viewer/windows/JFJochAlgorithmDocWindow.cpp b/viewer/windows/JFJochAlgorithmDocWindow.cpp new file mode 100644 index 00000000..2883458d --- /dev/null +++ b/viewer/windows/JFJochAlgorithmDocWindow.cpp @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochAlgorithmDocWindow.h" + +#include +#include +#include + +JFJochAlgorithmDocWindow::JFJochAlgorithmDocWindow(QWidget *parent) : QDialog(parent) { + setWindowTitle("Data Analysis Algorithms"); + resize(900, 700); + + auto *browser = new QTextBrowser(this); + browser->setOpenExternalLinks(true); + + QFile file(":/cpu_data_analysis.md"); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) + browser->setMarkdown(QString::fromUtf8(file.readAll())); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(browser); +} diff --git a/viewer/windows/JFJochAlgorithmDocWindow.h b/viewer/windows/JFJochAlgorithmDocWindow.h new file mode 100644 index 00000000..f09ab8b5 --- /dev/null +++ b/viewer/windows/JFJochAlgorithmDocWindow.h @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +// Modeless window showing the data-analysis algorithm documentation, rendered from the copy of +// docs/CPU_DATA_ANALYSIS.md baked into the binary (:/cpu_data_analysis.md), so it is available +// wherever the viewer runs and always matches the build. +class JFJochAlgorithmDocWindow : public QDialog { + Q_OBJECT +public: + explicit JFJochAlgorithmDocWindow(QWidget *parent = nullptr); +}; -- 2.54.0 From 38f1c1a3876d3bad960d2b86f9c2d834e6d909b3 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 26 Jul 2026 22:08:45 +0200 Subject: [PATCH 037/295] Revert "Viewer: read the data-analysis algorithm documentation from Help" This reverts commit 681e80e46. Dropped for now: QTextBrowser::setMarkdown leaves the inline LaTeX in CPU_DATA_ANALYSIS.md as raw "$...$" source, and the document's own HTML/math rendering wants sorting out first. Reverted rather than rewritten so the implementation stays available in history to bring back afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- viewer/CMakeLists.txt | 2 -- viewer/JFJochViewerMenu.cpp | 13 ------------ viewer/JFJochViewerMenu.h | 3 --- viewer/resources/resources.qrc | 3 --- viewer/windows/JFJochAlgorithmDocWindow.cpp | 23 --------------------- viewer/windows/JFJochAlgorithmDocWindow.h | 15 -------------- 6 files changed, 59 deletions(-) delete mode 100644 viewer/windows/JFJochAlgorithmDocWindow.cpp delete mode 100644 viewer/windows/JFJochAlgorithmDocWindow.h diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 0ef08999..9c311a5b 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -87,8 +87,6 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView windows/JFJochHelperWindow.h windows/JFJochLicenseWindow.cpp windows/JFJochLicenseWindow.h - windows/JFJochAlgorithmDocWindow.cpp - windows/JFJochAlgorithmDocWindow.h image_viewer/JFJochImage.cpp image_viewer/JFJochImage.h image_viewer/JFJochFollowerImage.cpp diff --git a/viewer/JFJochViewerMenu.cpp b/viewer/JFJochViewerMenu.cpp index 5719308c..8d039375 100644 --- a/viewer/JFJochViewerMenu.cpp +++ b/viewer/JFJochViewerMenu.cpp @@ -17,7 +17,6 @@ #include #include "JFJochViewerWindow.h" -#include "windows/JFJochAlgorithmDocWindow.h" #include "windows/JFJochLicenseWindow.h" #include "../common/GitInfo.h" #include "../common/CUDAWrapper.h" @@ -82,9 +81,6 @@ JFJochViewerMenu::JFJochViewerMenu(QWidget *parent) : QMenuBar(parent) { const QAction *aboutAction = helpMenu->addAction("About"); connect(aboutAction, &QAction::triggered, this, &JFJochViewerMenu::aboutSelected); - const QAction *algorithmDocAction = helpMenu->addAction("Data Analysis Algorithms"); - connect(algorithmDocAction, &QAction::triggered, this, &JFJochViewerMenu::algorithmDocSelected); - const QAction *licensesAction = helpMenu->addAction("Third-party Licenses"); connect(licensesAction, &QAction::triggered, this, &JFJochViewerMenu::licensesSelected); } @@ -152,15 +148,6 @@ void JFJochViewerMenu::licensesSelected() { licenseWindow->activateWindow(); } -void JFJochViewerMenu::algorithmDocSelected() { - // Same pattern as the licence window: created once, then raised. - if (algorithmDocWindow == nullptr) - algorithmDocWindow = new JFJochAlgorithmDocWindow(window()); - algorithmDocWindow->show(); - algorithmDocWindow->raise(); - algorithmDocWindow->activateWindow(); -} - void JFJochViewerMenu::openSelected() { QString fileName = QFileDialog::getOpenFileName( this, diff --git a/viewer/JFJochViewerMenu.h b/viewer/JFJochViewerMenu.h index 45eeea3b..50f49ef0 100644 --- a/viewer/JFJochViewerMenu.h +++ b/viewer/JFJochViewerMenu.h @@ -7,7 +7,6 @@ #include "windows/JFJochHelperWindow.h" -class JFJochAlgorithmDocWindow; class JFJochLicenseWindow; class QDockWidget; @@ -22,7 +21,6 @@ class JFJochViewerMenu : public QMenuBar { QMenu *windowMenu = nullptr; JFJochLicenseWindow *licenseWindow = nullptr; - JFJochAlgorithmDocWindow *algorithmDocWindow = nullptr; public: explicit JFJochViewerMenu(QWidget *parent = nullptr); ~JFJochViewerMenu() override = default; @@ -52,7 +50,6 @@ signals: private slots: void aboutSelected(); void licensesSelected(); - void algorithmDocSelected(); void quitSelected(); void closeSelected(); diff --git a/viewer/resources/resources.qrc b/viewer/resources/resources.qrc index 3eaa996e..6ec0fb4b 100644 --- a/viewer/resources/resources.qrc +++ b/viewer/resources/resources.qrc @@ -7,9 +7,6 @@ jfjoch.png third_party_licenses.html - - ../../docs/CPU_DATA_ANALYSIS.md psi_01.png psi_02.png psi_03.png diff --git a/viewer/windows/JFJochAlgorithmDocWindow.cpp b/viewer/windows/JFJochAlgorithmDocWindow.cpp deleted file mode 100644 index 2883458d..00000000 --- a/viewer/windows/JFJochAlgorithmDocWindow.cpp +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute -// SPDX-License-Identifier: GPL-3.0-only - -#include "JFJochAlgorithmDocWindow.h" - -#include -#include -#include - -JFJochAlgorithmDocWindow::JFJochAlgorithmDocWindow(QWidget *parent) : QDialog(parent) { - setWindowTitle("Data Analysis Algorithms"); - resize(900, 700); - - auto *browser = new QTextBrowser(this); - browser->setOpenExternalLinks(true); - - QFile file(":/cpu_data_analysis.md"); - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) - browser->setMarkdown(QString::fromUtf8(file.readAll())); - - auto *layout = new QVBoxLayout(this); - layout->addWidget(browser); -} diff --git a/viewer/windows/JFJochAlgorithmDocWindow.h b/viewer/windows/JFJochAlgorithmDocWindow.h deleted file mode 100644 index f09ab8b5..00000000 --- a/viewer/windows/JFJochAlgorithmDocWindow.h +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute -// SPDX-License-Identifier: GPL-3.0-only - -#pragma once - -#include - -// Modeless window showing the data-analysis algorithm documentation, rendered from the copy of -// docs/CPU_DATA_ANALYSIS.md baked into the binary (:/cpu_data_analysis.md), so it is available -// wherever the viewer runs and always matches the build. -class JFJochAlgorithmDocWindow : public QDialog { - Q_OBJECT -public: - explicit JFJochAlgorithmDocWindow(QWidget *parent = nullptr); -}; -- 2.54.0 From 16bf3408f0691795defb075912b10c9fffabf89f Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 09:07:00 +0200 Subject: [PATCH 038/295] Address code-review findings; make detection limits detector-driven One changeset, developed together in response to a review of this branch, so the files carry several of the changes at once. Full test suite passes (733 cases). Spot finding - Split ImageSpotFinder into Detect() (flag strong pixels - the expensive per-pixel pass) and ExtractSpots() (CCL + min/max-pix + resolution mask), with Run() = both. The per-image min-pix escalation now detects ONCE and repeats only the cheap extraction, instead of re-running the whole finder four times per frame as it did on the default path. It also keeps the winning attempt's spot list rather than re-extracting it, so the frame that is integrated is exactly the frame that was scored - which a GPU re-extract could not guarantee (float atomic ordering). - spot_finding_time_s no longer swallows indexing time, and indexing_time_s now sums every escalation call instead of reporting only the last. Detection limits follow the detector - The azimuthal-integration upper q and the spot-finding high-resolution limit are now std::optional, in the C++ structs AND in the OpenAPI schema, and resolve to the detector's own maximum (DiffractionExperiment::GetDetectorMaxQ_ recipA). Adaptive detection reads a pixel's ring from the azimuthal bins, so a pixel outside that q range could never be strong - the integration range silently bounded what detection could see, regardless of the requested resolution limit. Regenerated the C++ and TypeScript clients; the viewer and the web frontend each gained a "to detector edge" switch. Detection defaults are now per workflow (measured, not assumed) - Stills: adaptive detection, min-pix chosen per image, no resolution clipping. - Rotation: fixed-threshold finder, min-pix 2, 1.5 A limit. On a 33-crystal rotation battery, adaptive detection helped four hard crystals but deterministically broke three (a lost space group, a halved indexing rate, a collapsed merge), and the detector-edge limit cost indexing on a strong rotation set (100.0 -> 96.8%). Each is still overridable by its flag, and --no-adaptive-spots is new. Indexer seed escalation - Stop escalating once a seed's lattice explains >= 90% of the seed spots. Previously any frame with >= 80 spots always paid three indexer calls, online broker included. Merge-consistency filter - --min-image-cc gated on a per-image CC computed BEFORE the stills partiality post-refinement and never refreshed; the refiner now recomputes it, so the reported CC describes the data that are actually merged. - Replaced the per-call cc_mask argument with one MergeOnTheFly flag, so the merge, the error model and MergeStats can no longer disagree about which images are in (the --scale path merged unfiltered while its statistics were filtered). Per-image B-factor refinement (-B) removed - Measured on four serial-stills datasets: it is a no-op where the per-image fit is well conditioned and actively harmful where it is not (CC1/2 -8.1, R_meas +23.2 on the weakest large-cell set, whose fits hit their [-50, 200] bounds on 14-25% of images). It had also been silently DISCARDED since the partiality post-refinement landed - reported but not applied. Rather than fix and keep a knob with no demonstrated benefit, the flag and the whole image_scale_b_factor chain are gone: setting, scaling fit, message field, CBOR, HDF5 write and read-back, per-image plot, OpenAPI enum, viewer column and checkbox, docs. ScaleOnTheFly no longer needs Ceres at all - the fit is a linear IRLS. (The Wilson per-image b_factor is a different quantity and stays.) Stills partiality width now fits both of its components - sigma^2 = gamma0^2 + (gamma_e*d*)^2 instead of a purely angular gamma_e*d* with gamma0 pinned to 0. Fitted per crystal by least squares of dist_ewald^2 on d*^2. The angular-only width is fitted over a d*^2-dense population, so it was pinned by the high-resolution edge and collapsed at low d*: median partiality 0.008 beyond 13 A for reflections that were plainly recorded, 55% of them under the merge's partiality floor, and the survivors divided by those values - which inflated the merged low-resolution intensity scale 3.6x (~ +9 A^2 of apparent B). Measured on 5000 stills: the ramp flattens to 0.89x, no observation is dropped any more (701750 -> 716811), shell-mean CC1/2 and R-free improve slightly. Note CC1/2, R_meas, completeness and a B-refining R-free are all blind to that ramp, which is why it survived earlier validation; the cost is high-resolution R_meas (98.5 -> 101.9 shell-averaged). Removed dead code from add-then-remove churn - Prediction-time "still partiality" (unreachable: no setter), the phantom IndexingSettings::min_indexed_spot_fraction knob (getter, no setter - now the constant it always was), StillsPartialityRefine's caller-less Settings constructor and its reference to a long-gone env var, ProcessImage's unread bool return, an unused include, and a dead viewer overlay hook. Also - Viewer: the magnifier compared a QImage with itself, so its scene rect was set once ever and it could not pan into a larger dataset; the hover tail timer could fire after leaveEvent and resurrect the resolution readout outside the image. - update_version.sh regenerated the frontend lock file BEFORE bumping the version (every release shipped an off-by-one lock), and did git rm/git add on a path that has not existed since the client moved to src/client - with no set -e, both failed silently. - fpga/pcie_driver/postinstall.sh tested "[ ! occurrences > 0 ]", which is a redirect, not a test, so dkms add never ran. - Unit tests for the adaptive-threshold host functions, which had none. Co-Authored-By: Claude Opus 5 (1M context) --- acquisition_device/AcquisitionDevice.cpp | 2 +- broker/OpenAPIConvert.cpp | 14 +- broker/gen/model/Azim_int_settings.cpp | 27 +++- broker/gen/model/Azim_int_settings.h | 6 +- broker/gen/model/Spot_finding_settings.cpp | 23 ++- broker/gen/model/Spot_finding_settings.h | 6 +- broker/jfjoch_api.yaml | 11 +- common/AzimuthalIntegrationSettings.cpp | 31 +++- common/AzimuthalIntegrationSettings.h | 11 +- common/BraggIntegrationSettings.cpp | 9 -- common/BraggIntegrationSettings.h | 3 - common/DiffractionExperiment.cpp | 40 ++++- common/DiffractionExperiment.h | 8 +- common/IndexingSettings.cpp | 4 - common/IndexingSettings.h | 5 - common/JFJochMessages.h | 2 - common/JFJochReceiverPlots.cpp | 8 - common/JFJochReceiverPlots.h | 1 - common/Plot.h | 2 +- common/ScalingSettings.cpp | 17 --- common/ScalingSettings.h | 9 -- docs/CBOR.md | 2 - docs/CPU_DATA_ANALYSIS.md | 16 +- docs/HDF5.md | 1 - docs/RUGNUX.md | 21 ++- fpga/pcie_driver/postinstall.sh | 2 +- frame_serialize/CBORStream2Deserializer.cpp | 4 - frame_serialize/CBORStream2Serializer.cpp | 2 - frontend/package-lock.json | 4 +- frontend/src/client/types.gen.ts | 19 ++- frontend/src/client/zod.gen.ts | 7 +- frontend/src/components/AzIntSettings.tsx | 19 ++- .../src/components/DataProcessingSettings.tsx | 21 ++- image_analysis/IndexAndRefine.cpp | 14 +- image_analysis/IndexAndRefine.h | 2 +- image_analysis/IntegrationOutcome.h | 1 - image_analysis/MXAnalysisWithoutFPGA.cpp | 55 ++++--- image_analysis/MXAnalysisWithoutFPGA.h | 4 +- .../bragg_prediction/BraggPrediction.cpp | 9 +- .../bragg_prediction/BraggPrediction.h | 7 - .../bragg_prediction/BraggPredictionGPU.cu | 17 +-- .../bragg_prediction/BraggPredictionGPU.h | 2 - image_analysis/indexing/AnalyzeIndexing.cpp | 5 +- .../rotation_indexer/RotationIndexer.cpp | 7 +- image_analysis/scale_merge/Merge.cpp | 79 ++++++++-- image_analysis/scale_merge/Merge.h | 23 ++- .../scale_merge/RotationScaleMerge.cpp | 3 +- image_analysis/scale_merge/ScaleOnTheFly.cpp | 140 ++---------------- image_analysis/scale_merge/ScaleOnTheFly.h | 7 +- image_analysis/scale_merge/ScalingResult.cpp | 6 +- image_analysis/scale_merge/ScalingResult.h | 1 - .../scale_merge/StillsPartialityRefine.cpp | 84 +++++++---- .../scale_merge/StillsPartialityRefine.h | 6 +- .../spot_finding/AdaptiveSpotFinderCPU.cpp | 16 +- .../spot_finding/AdaptiveSpotFinderCPU.h | 4 +- .../spot_finding/AdaptiveSpotFinderGPU.cu | 17 +-- .../spot_finding/AdaptiveSpotFinderGPU.h | 6 +- .../spot_finding/DetModuleSpotFinder_cpu.h | 2 +- .../spot_finding/ImageSpotFinder.cpp | 7 + image_analysis/spot_finding/ImageSpotFinder.h | 10 +- .../spot_finding/ImageSpotFinderCPU.cpp | 7 +- .../spot_finding/ImageSpotFinderCPU.h | 2 +- .../spot_finding/ImageSpotFinderGPU.cu | 4 +- .../spot_finding/ImageSpotFinderGPU.h | 2 +- .../spot_finding/SpotFindingSettings.h | 5 +- image_analysis/spot_finding/SpotUtils.cpp | 3 +- reader/HDF5MetadataSource.cpp | 3 - reader/JFJochReaderDataset.h | 1 - rugnux/Rugnux.cpp | 29 ++-- rugnux/RugnuxCommandLine.cpp | 14 +- rugnux/rugnux_cli.cpp | 72 +++++---- tests/AdaptiveThresholdTest.cpp | 100 +++++++++++++ tests/CMakeLists.txt | 2 + tests/JFJochReaderTest.cpp | 6 + tests/XDSPluginTest.cpp | 2 + update_version.sh | 18 ++- viewer/JFJochHttpReader.cpp | 1 - viewer/JFJochProcessController.cpp | 1 - viewer/JFJochViewerWindow.cpp | 7 +- .../image_viewer/JFJochDiffractionImage.cpp | 5 - viewer/image_viewer/JFJochDiffractionImage.h | 1 - viewer/image_viewer/JFJochFollowerImage.cpp | 8 +- viewer/image_viewer/JFJochFollowerImage.h | 1 + viewer/image_viewer/JFJochImage.cpp | 10 +- viewer/image_viewer/JFJochImage.h | 2 +- viewer/widgets/JFJochViewerSettingsDock.cpp | 82 +++++++--- viewer/widgets/JFJochViewerSettingsDock.h | 6 + .../windows/JFJochViewerImageListWindow.cpp | 23 +-- viewer/windows/JFJochViewerImageListWindow.h | 3 +- writer/HDF5DataFilePluginMX.cpp | 3 - writer/HDF5DataFilePluginMX.h | 1 - writer/HDF5NXmx.cpp | 1 - 92 files changed, 762 insertions(+), 554 deletions(-) create mode 100644 tests/AdaptiveThresholdTest.cpp diff --git a/acquisition_device/AcquisitionDevice.cpp b/acquisition_device/AcquisitionDevice.cpp index ed410ab9..00fecac8 100644 --- a/acquisition_device/AcquisitionDevice.cpp +++ b/acquisition_device/AcquisitionDevice.cpp @@ -323,7 +323,7 @@ void AcquisitionDevice::SetSpotFinderParameters(const SpotFindingSettings &setti fpga_parameters.snr_threshold = settings.signal_to_noise_threshold; fpga_parameters.count_threshold = settings.photon_count_threshold; fpga_parameters.max_d = settings.low_resolution_limit; - fpga_parameters.min_d = settings.high_resolution_limit; + fpga_parameters.min_d = settings.high_resolution_limit.value_or(0.0f); fpga_parameters.min_pix_per_spot = settings.min_pix_per_spot.value_or(2); HW_SetSpotFinderParameters(fpga_parameters); } diff --git a/broker/OpenAPIConvert.cpp b/broker/OpenAPIConvert.cpp index 4783dcb2..3d6378e0 100644 --- a/broker/OpenAPIConvert.cpp +++ b/broker/OpenAPIConvert.cpp @@ -16,7 +16,8 @@ SpotFindingSettings Convert(const org::openapitools::server::model::Spot_finding ret.photon_count_threshold = input.getPhotonCountThreshold(); ret.min_pix_per_spot = input.getMinPixPerSpot(); ret.max_pix_per_spot = input.getMaxPixPerSpot(); - ret.high_resolution_limit = input.getHighResolutionLimit(); + if (input.highResolutionLimitIsSet()) + ret.high_resolution_limit = input.getHighResolutionLimit(); ret.low_resolution_limit = input.getLowResolutionLimit(); ret.enable = input.isEnable(); ret.indexing = input.isIndexing(); @@ -34,7 +35,8 @@ org::openapitools::server::model::Spot_finding_settings Convert(const SpotFindin ret.setPhotonCountThreshold(input.photon_count_threshold); ret.setMinPixPerSpot(input.min_pix_per_spot.value_or(2)); ret.setMaxPixPerSpot(input.max_pix_per_spot); - ret.setHighResolutionLimit(input.high_resolution_limit); + if (input.high_resolution_limit.has_value()) + ret.setHighResolutionLimit(input.high_resolution_limit.value()); ret.setLowResolutionLimit(input.low_resolution_limit); ret.setEnable(input.enable); ret.setIndexing(input.indexing); @@ -437,7 +439,9 @@ AzimuthalIntegrationSettings Convert(const org::openapitools::server::model::Azi ret.SolidAngleCorrection(input.isSolidAngleCorr()); ret.PolarizationCorrection(input.isPolarizationCorr()); ret.QSpacing_recipA(input.getQSpacing()); - ret.QRange_recipA(input.getLowQRecipA(), input.getHighQRecipA()); + ret.QRange_recipA(input.getLowQRecipA(), + input.highQRecipAIsSet() ? std::optional(input.getHighQRecipA()) + : std::nullopt); ret.AzimuthalBinCount(input.getAzimuthalBins()); ret.ForceCPUinFPGAWorkflow(input.isForceCpu()); return ret; @@ -447,7 +451,8 @@ org::openapitools::server::model::Azim_int_settings Convert(const AzimuthalInteg org::openapitools::server::model::Azim_int_settings ret{}; ret.setSolidAngleCorr(settings.IsSolidAngleCorrection()); ret.setPolarizationCorr(settings.IsPolarizationCorrection()); - ret.setHighQRecipA(settings.GetHighQ_recipA()); + if (const auto high_q = settings.GetRequestedHighQ_recipA()) + ret.setHighQRecipA(high_q.value()); ret.setLowQRecipA(settings.GetLowQ_recipA()); ret.setQSpacing(settings.GetQSpacing_recipA()); ret.setAzimuthalBins(settings.GetAzimuthalBinCount()); @@ -910,7 +915,6 @@ PlotType ConvertPlotType(const std::optional& input) { if (input == "integrated_reflections") return PlotType::IntegratedReflections; if (input == "image_scale_factor") return PlotType::ImageScaleFactor; if (input == "image_scale_cc") return PlotType::ImageScaleCC; - if (input == "image_scale_b") return PlotType::ImageScaleBFactor; if (input == "compression_ratio") return PlotType::CompressionRatio; if (input == "indexing_lattice_count") return PlotType::IndexingLatticeCount; throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, diff --git a/broker/gen/model/Azim_int_settings.cpp b/broker/gen/model/Azim_int_settings.cpp index f52d9820..1386b470 100644 --- a/broker/gen/model/Azim_int_settings.cpp +++ b/broker/gen/model/Azim_int_settings.cpp @@ -24,6 +24,7 @@ Azim_int_settings::Azim_int_settings() m_Polarization_corr = true; m_Solid_angle_corr = true; m_High_q_recipA = 0.0f; + m_High_q_recipAIsSet = false; m_Low_q_recipA = 0.0f; m_Q_spacing = 0.0f; m_Azimuthal_bins = 1L; @@ -53,8 +54,8 @@ bool Azim_int_settings::validate(std::stringstream& msg, const std::string& path const std::string _pathPrefix = pathPrefix.empty() ? "Azim_int_settings" : pathPrefix; - - /* High_q_recipA */ { + if (highQRecipAIsSet()) + { const float& value = m_High_q_recipA; const std::string currentValuePath = _pathPrefix + ".highQRecipA"; @@ -138,8 +139,8 @@ bool Azim_int_settings::operator==(const Azim_int_settings& rhs) const (isSolidAngleCorr() == rhs.isSolidAngleCorr()) && - (getHighQRecipA() == rhs.getHighQRecipA()) - && + + ((!highQRecipAIsSet() && !rhs.highQRecipAIsSet()) || (highQRecipAIsSet() && rhs.highQRecipAIsSet() && getHighQRecipA() == rhs.getHighQRecipA())) && (getLowQRecipA() == rhs.getLowQRecipA()) && @@ -166,7 +167,8 @@ void to_json(nlohmann::json& j, const Azim_int_settings& o) j = nlohmann::json::object(); j["polarization_corr"] = o.m_Polarization_corr; j["solid_angle_corr"] = o.m_Solid_angle_corr; - j["high_q_recipA"] = o.m_High_q_recipA; + if(o.highQRecipAIsSet()) + j["high_q_recipA"] = o.m_High_q_recipA; j["low_q_recipA"] = o.m_Low_q_recipA; j["q_spacing"] = o.m_Q_spacing; if(o.azimuthalBinsIsSet()) @@ -180,7 +182,11 @@ void from_json(const nlohmann::json& j, Azim_int_settings& o) { j.at("polarization_corr").get_to(o.m_Polarization_corr); j.at("solid_angle_corr").get_to(o.m_Solid_angle_corr); - j.at("high_q_recipA").get_to(o.m_High_q_recipA); + if(j.find("high_q_recipA") != j.end()) + { + j.at("high_q_recipA").get_to(o.m_High_q_recipA); + o.m_High_q_recipAIsSet = true; + } j.at("low_q_recipA").get_to(o.m_Low_q_recipA); j.at("q_spacing").get_to(o.m_Q_spacing); if(j.find("azimuthal_bins") != j.end()) @@ -219,6 +225,15 @@ float Azim_int_settings::getHighQRecipA() const void Azim_int_settings::setHighQRecipA(float const value) { m_High_q_recipA = value; + m_High_q_recipAIsSet = true; +} +bool Azim_int_settings::highQRecipAIsSet() const +{ + return m_High_q_recipAIsSet; +} +void Azim_int_settings::unsetHigh_q_recipA() +{ + m_High_q_recipAIsSet = false; } float Azim_int_settings::getLowQRecipA() const { diff --git a/broker/gen/model/Azim_int_settings.h b/broker/gen/model/Azim_int_settings.h index a412f93f..51889fd7 100644 --- a/broker/gen/model/Azim_int_settings.h +++ b/broker/gen/model/Azim_int_settings.h @@ -68,10 +68,12 @@ public: bool isSolidAngleCorr() const; void setSolidAngleCorr(bool const value); /// - /// + /// Upper q limit of the azimuthal integration [1/Angstrom]. Optional: if omitted, the integration (and the adaptive spot detection that shares these q bins) extends to the highest q the detector reaches. /// float getHighQRecipA() const; void setHighQRecipA(float const value); + bool highQRecipAIsSet() const; + void unsetHigh_q_recipA(); /// /// /// @@ -105,7 +107,7 @@ protected: bool m_Solid_angle_corr; float m_High_q_recipA; - + bool m_High_q_recipAIsSet; float m_Low_q_recipA; float m_Q_spacing; diff --git a/broker/gen/model/Spot_finding_settings.cpp b/broker/gen/model/Spot_finding_settings.cpp index 7f178aca..b6cc66b7 100644 --- a/broker/gen/model/Spot_finding_settings.cpp +++ b/broker/gen/model/Spot_finding_settings.cpp @@ -28,6 +28,7 @@ Spot_finding_settings::Spot_finding_settings() m_Min_pix_per_spot = 0L; m_Max_pix_per_spot = 0L; m_High_resolution_limit = 0.0f; + m_High_resolution_limitIsSet = false; m_Low_resolution_limit = 0.0f; m_High_resolution_limit_for_spot_count_low_res = 0.0f; m_Quick_integration = false; @@ -196,8 +197,8 @@ bool Spot_finding_settings::operator==(const Spot_finding_settings& rhs) const (getMaxPixPerSpot() == rhs.getMaxPixPerSpot()) && - (getHighResolutionLimit() == rhs.getHighResolutionLimit()) - && + + ((!highResolutionLimitIsSet() && !rhs.highResolutionLimitIsSet()) || (highResolutionLimitIsSet() && rhs.highResolutionLimitIsSet() && getHighResolutionLimit() == rhs.getHighResolutionLimit())) && (getLowResolutionLimit() == rhs.getLowResolutionLimit()) && @@ -231,7 +232,8 @@ void to_json(nlohmann::json& j, const Spot_finding_settings& o) j["photon_count_threshold"] = o.m_Photon_count_threshold; j["min_pix_per_spot"] = o.m_Min_pix_per_spot; j["max_pix_per_spot"] = o.m_Max_pix_per_spot; - j["high_resolution_limit"] = o.m_High_resolution_limit; + if(o.highResolutionLimitIsSet()) + j["high_resolution_limit"] = o.m_High_resolution_limit; j["low_resolution_limit"] = o.m_Low_resolution_limit; j["high_resolution_limit_for_spot_count_low_res"] = o.m_High_resolution_limit_for_spot_count_low_res; j["quick_integration"] = o.m_Quick_integration; @@ -249,7 +251,11 @@ void from_json(const nlohmann::json& j, Spot_finding_settings& o) j.at("photon_count_threshold").get_to(o.m_Photon_count_threshold); j.at("min_pix_per_spot").get_to(o.m_Min_pix_per_spot); j.at("max_pix_per_spot").get_to(o.m_Max_pix_per_spot); - j.at("high_resolution_limit").get_to(o.m_High_resolution_limit); + if(j.find("high_resolution_limit") != j.end()) + { + j.at("high_resolution_limit").get_to(o.m_High_resolution_limit); + o.m_High_resolution_limitIsSet = true; + } j.at("low_resolution_limit").get_to(o.m_Low_resolution_limit); j.at("high_resolution_limit_for_spot_count_low_res").get_to(o.m_High_resolution_limit_for_spot_count_low_res); j.at("quick_integration").get_to(o.m_Quick_integration); @@ -317,6 +323,15 @@ float Spot_finding_settings::getHighResolutionLimit() const void Spot_finding_settings::setHighResolutionLimit(float const value) { m_High_resolution_limit = value; + m_High_resolution_limitIsSet = true; +} +bool Spot_finding_settings::highResolutionLimitIsSet() const +{ + return m_High_resolution_limitIsSet; +} +void Spot_finding_settings::unsetHigh_resolution_limit() +{ + m_High_resolution_limitIsSet = false; } float Spot_finding_settings::getLowResolutionLimit() const { diff --git a/broker/gen/model/Spot_finding_settings.h b/broker/gen/model/Spot_finding_settings.h index 4eb5f17c..6acf3381 100644 --- a/broker/gen/model/Spot_finding_settings.h +++ b/broker/gen/model/Spot_finding_settings.h @@ -88,10 +88,12 @@ public: int64_t getMaxPixPerSpot() const; void setMaxPixPerSpot(int64_t const value); /// - /// High resolution limit for spot finding [Angstrom] + /// High resolution limit for spot finding [Angstrom]. Optional: if omitted, spot finding extends as far as the detector reaches, i.e. the detection is not clipped in resolution. /// float getHighResolutionLimit() const; void setHighResolutionLimit(float const value); + bool highResolutionLimitIsSet() const; + void unsetHigh_resolution_limit(); /// /// Low resolution limit for spot finding [Angstrom] /// @@ -136,7 +138,7 @@ protected: int64_t m_Max_pix_per_spot; float m_High_resolution_limit; - + bool m_High_resolution_limitIsSet; float m_Low_resolution_limit; float m_High_resolution_limit_for_spot_count_low_res; diff --git a/broker/jfjoch_api.yaml b/broker/jfjoch_api.yaml index 67a4e5fc..4385fe5a 100644 --- a/broker/jfjoch_api.yaml +++ b/broker/jfjoch_api.yaml @@ -119,7 +119,6 @@ components: - integrated_reflections - image_scale_factor - image_scale_cc - - image_scale_b - compression_ratio - ice_ring_score roi: @@ -1039,7 +1038,6 @@ components: - photon_count_threshold - max_pix_per_spot - min_pix_per_spot - - high_resolution_limit - low_resolution_limit - quick_integration - high_resolution_limit_for_spot_count_low_res @@ -1075,7 +1073,9 @@ components: high_resolution_limit: type: number format: float - description: High resolution limit for spot finding [Angstrom] + description: | + High resolution limit for spot finding [Angstrom]. Optional: if omitted, spot finding extends + as far as the detector reaches, i.e. the detection is not clipped in resolution. low_resolution_limit: type: number format: float @@ -1115,7 +1115,6 @@ components: required: - solid_angle_corr - polarization_corr - - high_q_recipA - low_q_recipA - q_spacing properties: @@ -1132,6 +1131,10 @@ components: minimum: 2e-5 maximum: 10.0 format: float + description: | + Upper q limit of the azimuthal integration [1/Angstrom]. Optional: if omitted, the integration + (and the adaptive spot detection that shares these q bins) extends to the highest q the + detector reaches. low_q_recipA: type: number format: float diff --git a/common/AzimuthalIntegrationSettings.cpp b/common/AzimuthalIntegrationSettings.cpp index 76ccf4ff..de5504c9 100644 --- a/common/AzimuthalIntegrationSettings.cpp +++ b/common/AzimuthalIntegrationSettings.cpp @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only +#include #include #include "AzimuthalIntegrationSettings.h" @@ -19,21 +20,33 @@ AzimuthalIntegrationSettings &AzimuthalIntegrationSettings::SolidAngleCorrection return *this; } -AzimuthalIntegrationSettings &AzimuthalIntegrationSettings::QRange_recipA(float low, float high) { +AzimuthalIntegrationSettings &AzimuthalIntegrationSettings::QRange_recipA(float low, std::optional high) { check_finite("Low Q for azimuthal integration", low); - check_finite("High Q for azimuthal integration", high); - check_max("High Q for azimuthal integration", high, maxQ_recipA); check_min("Low Q for azimuthal integration", low, minQ_recipA); - if (high <= low) - throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "High Q must be higher than low Q"); + if (high.has_value()) { + check_finite("High Q for azimuthal integration", *high); + check_max("High Q for azimuthal integration", *high, maxQ_recipA); + if (*high <= low) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "High Q must be higher than low Q"); + } - high_q_recipA = high; + requested_high_q_recipA = high; low_q_recipA = low; + // Until ResolveHighQ runs, an unset limit keeps the value the bins were last built from. + high_q_recipA = high.value_or(high_q_recipA); UpdateBinCount(); return *this; } +void AzimuthalIntegrationSettings::ResolveHighQ(float detector_max_q_recipA) { + if (requested_high_q_recipA.has_value()) + return; + + high_q_recipA = std::clamp(detector_max_q_recipA, low_q_recipA + q_spacing, maxQ_recipA); + UpdateBinCount(); +} + AzimuthalIntegrationSettings &AzimuthalIntegrationSettings::QSpacing_recipA(float input) { check_finite("Q spacing for azimuthal integration", input); check_min("Q spacing for azimuthal integration", input, minQ_recipA); @@ -50,6 +63,10 @@ float AzimuthalIntegrationSettings::GetHighQ_recipA() const { return high_q_recipA; } +std::optional AzimuthalIntegrationSettings::GetRequestedHighQ_recipA() const { + return requested_high_q_recipA; +} + float AzimuthalIntegrationSettings::GetLowQ_recipA() const { return low_q_recipA; } diff --git a/common/AzimuthalIntegrationSettings.h b/common/AzimuthalIntegrationSettings.h index cc750657..63835231 100644 --- a/common/AzimuthalIntegrationSettings.h +++ b/common/AzimuthalIntegrationSettings.h @@ -13,6 +13,12 @@ class AzimuthalIntegrationSettings { bool solid_angle_correction = true; bool polarization_correction = true; + // Requested upper q limit. Unset means "as far as the detector reaches": DiffractionExperiment + // resolves it from the geometry (ResolveHighQ) whenever it hands these settings out, so + // high_q_recipA below - what the bins are built from - is always a concrete number. Not clipping + // detection at an arbitrary default matters for the adaptive spot finder, which bins pixels through + // this same q range and cannot see a pixel that falls outside it. + std::optional requested_high_q_recipA; float high_q_recipA = 5.0; float low_q_recipA = 0.1; float bkg_estimate_high_q_recipA = 2.0f * PI / 3.0; @@ -31,7 +37,9 @@ public: AzimuthalIntegrationSettings(); AzimuthalIntegrationSettings& SolidAngleCorrection(bool input); AzimuthalIntegrationSettings& PolarizationCorrection(bool input); - AzimuthalIntegrationSettings& QRange_recipA(float low, float high); + AzimuthalIntegrationSettings& QRange_recipA(float low, std::optional high); + // Substitute the detector's own maximum q for an unset high q. No-op if one was requested. + void ResolveHighQ(float detector_max_q_recipA); AzimuthalIntegrationSettings& QSpacing_recipA(float input); AzimuthalIntegrationSettings& BkgEstimateQRange_recipA(float low, float high); AzimuthalIntegrationSettings& AzimuthalBinCount(int32_t input); @@ -40,6 +48,7 @@ public: [[nodiscard]] bool IsSolidAngleCorrection() const; [[nodiscard]] bool IsPolarizationCorrection() const; [[nodiscard]] float GetHighQ_recipA() const; + [[nodiscard]] std::optional GetRequestedHighQ_recipA() const; [[nodiscard]] float GetLowQ_recipA() const; [[nodiscard]] float GetQSpacing_recipA() const; diff --git a/common/BraggIntegrationSettings.cpp b/common/BraggIntegrationSettings.cpp index 338743d8..aa8ae524 100644 --- a/common/BraggIntegrationSettings.cpp +++ b/common/BraggIntegrationSettings.cpp @@ -97,15 +97,6 @@ float BraggIntegrationSettings::GetMinimumSigmaInRegardsToI() const { return minimum_sigma_in_regards_to_i; } -BraggIntegrationSettings &BraggIntegrationSettings::StillPartiality(bool input) { - still_partiality = input; - return *this; -} - -bool BraggIntegrationSettings::GetStillPartiality() const { - return still_partiality; -} - BraggIntegrationSettings &BraggIntegrationSettings::BackgroundTrimFraction(float input) { check_finite("Background trim fraction", input); check_min("Background trim fraction", input, 0.0); diff --git a/common/BraggIntegrationSettings.h b/common/BraggIntegrationSettings.h index 36b31b9f..974dc47c 100644 --- a/common/BraggIntegrationSettings.h +++ b/common/BraggIntegrationSettings.h @@ -20,7 +20,6 @@ class BraggIntegrationSettings { float d_min_limit_A = 1.0; std::optional fixed_profile_radius; float minimum_sigma_in_regards_to_i = 0.02; - bool still_partiality = false; // experimental stills excitation-error partiality (rugnux --still-partiality) // Symmetric trimmed-mean fraction for the r2..r3 background ring: drop the lowest and highest this // fraction of ring pixels before averaging. Resists the high-side contamination (neighbour-spot // wings, tails, zingers) that biases the plain ring mean up and makes it over-subtract weak @@ -36,7 +35,6 @@ public: BraggIntegrationSettings& DMinLimit_A(float input); BraggIntegrationSettings& FixedProfileRadius_recipA(std::optional input); BraggIntegrationSettings& Integrator(IntegratorMode input); - BraggIntegrationSettings& StillPartiality(bool input); BraggIntegrationSettings& BackgroundTrimFraction(float input); @@ -48,6 +46,5 @@ public: [[nodiscard]] float GetDMinLimit_A() const; [[nodiscard]] float GetMinimumSigmaInRegardsToI() const; - [[nodiscard]] bool GetStillPartiality() const; [[nodiscard]] float GetBackgroundTrimFraction() const; }; diff --git a/common/DiffractionExperiment.cpp b/common/DiffractionExperiment.cpp index 38f50880..21068756 100644 --- a/common/DiffractionExperiment.cpp +++ b/common/DiffractionExperiment.cpp @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only +#include #include #include @@ -164,7 +165,7 @@ DiffractionExperiment &DiffractionExperiment::MaskChipEdges(bool input) { return *this; } -DiffractionExperiment &DiffractionExperiment::QRangeForAzimInt_recipA(float low, float high) { +DiffractionExperiment &DiffractionExperiment::QRangeForAzimInt_recipA(float low, std::optional high) { az_integration_settings.QRange_recipA(low, high); return *this; } @@ -581,7 +582,7 @@ float DiffractionExperiment::GetLowQForAzimInt_recipA() const { return az_integration_settings.GetLowQ_recipA(); } float DiffractionExperiment::GetHighQForAzimInt_recipA() const { - return az_integration_settings.GetHighQ_recipA(); + return GetAzimuthalIntegrationSettings().GetHighQ_recipA(); } float DiffractionExperiment::GetQSpacingForAzimInt_recipA() const { @@ -610,15 +611,16 @@ void DiffractionExperiment::CheckDataProcessingSettings(const SpotFindingSetting check_min("Photon count threshold", settings.photon_count_threshold, 0); check_min("Minimum pixels per spot", settings.min_pix_per_spot.value_or(2), 1); check_min("Maximum pixels per spot", settings.max_pix_per_spot, settings.min_pix_per_spot.value_or(2) + 1); - check_finite("Spot finding high resolution limit", settings.high_resolution_limit); check_finite("Spot finding low resolution limit", settings.low_resolution_limit); - if (settings.high_resolution_limit > 0) { - check_min("Spot finding high resolution limit", settings.high_resolution_limit, 0.5); - check_max("Spot finding high resolution limit", settings.high_resolution_limit, 50.0); + // An unset high-resolution limit means "as far as the detector reaches", so there is nothing to check. + if (settings.high_resolution_limit.value_or(0.0f) > 0) { + check_finite("Spot finding high resolution limit", *settings.high_resolution_limit); + check_min("Spot finding high resolution limit", *settings.high_resolution_limit, 0.5); + check_max("Spot finding high resolution limit", *settings.high_resolution_limit, 50.0); if (settings.low_resolution_limit > 0) { check_min("Spot finding low resolution limit", settings.low_resolution_limit, - settings.high_resolution_limit); + *settings.high_resolution_limit); } } else if (settings.low_resolution_limit > 0) { check_min("Spot finding low resolution limit", settings.low_resolution_limit, 1.0); @@ -1316,7 +1318,29 @@ DiffractionExperiment &DiffractionExperiment::ImportAzimuthalIntegrationSettings } AzimuthalIntegrationSettings DiffractionExperiment::GetAzimuthalIntegrationSettings() const { - return az_integration_settings; + // An unset high q means "as far as the detector reaches", so resolve it here, where the geometry is + // known. Everyone reads the settings through this getter, so nobody sees an unresolved q range. + AzimuthalIntegrationSettings ret = az_integration_settings; + ret.ResolveHighQ(GetDetectorMaxQ_recipA()); + return ret; +} + +float DiffractionExperiment::GetDetectorMaxResolution_A() const { + const float q = GetDetectorMaxQ_recipA(); + return q > 0.0f ? 2.0f * static_cast(PI) / q : 0.0f; +} + +float DiffractionExperiment::GetDetectorMaxQ_recipA() const { + const DiffractionGeometry geom = GetDiffractionGeometry(); + const auto width = static_cast(GetXPixelsNumConv()); + const auto height = static_cast(GetYPixelsNumConv()); + + // The largest scattering angle sits at one of the detector corners, wherever the beam centre is. + float q = 0.0f; + for (const float x: {0.0f, width}) + for (const float y: {0.0f, height}) + q = std::max(q, geom.PxlToQ(x, y)); + return q; } DiffractionExperiment &DiffractionExperiment::PolarizationFactor(const std::optional &input) { diff --git a/common/DiffractionExperiment.h b/common/DiffractionExperiment.h index 98167523..f428961d 100644 --- a/common/DiffractionExperiment.h +++ b/common/DiffractionExperiment.h @@ -105,7 +105,7 @@ public: DiffractionExperiment& MaskModuleEdges(bool input); DiffractionExperiment& MaskChipEdges(bool input); - DiffractionExperiment& QRangeForAzimInt_recipA(float low, float high); + DiffractionExperiment& QRangeForAzimInt_recipA(float low, std::optional high); DiffractionExperiment& BkgEstimateQRange_recipA(float low, float high); DiffractionExperiment& QSpacingForAzimInt_recipA(float input); @@ -175,6 +175,12 @@ public: DiffractionExperiment& ImportAzimuthalIntegrationSettings(const AzimuthalIntegrationSettings& input); AzimuthalIntegrationSettings GetAzimuthalIntegrationSettings() const; + // Highest q (2*pi/d) any pixel of the detector reaches, from the current geometry, and the same + // limit as a resolution in Angstrom. This is what an unset azimuthal-integration high q and an unset + // spot-finding high-resolution limit resolve to. + [[nodiscard]] float GetDetectorMaxQ_recipA() const; + [[nodiscard]] float GetDetectorMaxResolution_A() const; + DiffractionExperiment& ImportBraggIntegrationSettings(const BraggIntegrationSettings& input); BraggIntegrationSettings GetBraggIntegrationSettings() const; diff --git a/common/IndexingSettings.cpp b/common/IndexingSettings.cpp index 122bd0e8..2de46bcb 100644 --- a/common/IndexingSettings.cpp +++ b/common/IndexingSettings.cpp @@ -28,10 +28,6 @@ int64_t IndexingSettings::GetViableCellMinSpots() const { return viable_cell_min_spots; } -float IndexingSettings::GetMinIndexedSpotFraction() const { - return min_indexed_spot_fraction; -} - IndexingSettings &IndexingSettings::Algorithm(IndexingAlgorithmEnum input) { switch (input) { case IndexingAlgorithmEnum::Auto: diff --git a/common/IndexingSettings.h b/common/IndexingSettings.h index 7c8ee11d..258e15ba 100644 --- a/common/IndexingSettings.h +++ b/common/IndexingSettings.h @@ -24,10 +24,6 @@ class IndexingSettings { static constexpr float unit_cell_angle_tolerance_deg = 5.0; // degree int64_t indexing_threads = 4; int64_t viable_cell_min_spots = 9; - // Minimum fraction of the in-resolution spots a candidate lattice must index to be accepted. - // Lowering it admits weaker/sparser crystals (more real ones on flooded XFEL frames, but also - // more spurious lattices that a downstream merge-consistency gate must remove). - float min_indexed_spot_fraction = 0.20f; int64_t max_extra_lattices = 2; @@ -62,7 +58,6 @@ public: IndexingSettings& MaxExtraLattices(int64_t input); [[nodiscard]] int64_t GetViableCellMinSpots() const; - [[nodiscard]] float GetMinIndexedSpotFraction() const; [[nodiscard]] IndexingAlgorithmEnum GetAlgorithm() const; [[nodiscard]] GeomRefinementAlgorithmEnum GetGeomRefinementAlgorithm() const; [[nodiscard]] float GetFFT_MaxUnitCell_A() const; diff --git a/common/JFJochMessages.h b/common/JFJochMessages.h index a884d4c9..940bd37c 100644 --- a/common/JFJochMessages.h +++ b/common/JFJochMessages.h @@ -185,7 +185,6 @@ struct DataMessage { std::optional image_scale_factor; std::optional image_scale_cc; std::optional image_scale_mosaicity; - std::optional image_scale_b_factor; std::optional compression_ratio; }; @@ -386,7 +385,6 @@ struct EndMessage { std::vector image_scale_factor; std::vector image_scale_cc; - std::vector image_scale_b_factor; std::vector image_scale_mosaicity; std::vector ice_ring_score; }; diff --git a/common/JFJochReceiverPlots.cpp b/common/JFJochReceiverPlots.cpp index 16a22b50..7802dadc 100644 --- a/common/JFJochReceiverPlots.cpp +++ b/common/JFJochReceiverPlots.cpp @@ -109,7 +109,6 @@ void JFJochReceiverPlots::Setup(const DiffractionExperiment &experiment, const A integrated_reflections.Clear(r); image_scale_factor.Clear(r); image_scale_cc.Clear(r); - image_scale_b.Clear(r); refinement_time.Clear(r); spot_finding_time.Clear(r); @@ -180,7 +179,6 @@ void JFJochReceiverPlots::Add(const DataMessage &msg, const AzimuthalIntegration indexing_solution.AddElement(msg.number, msg.indexing_result); image_scale_factor.AddElement(msg.number, msg.image_scale_factor); image_scale_cc.AddElement(msg.number, msg.image_scale_cc); - image_scale_b.AddElement(msg.number, msg.image_scale_b_factor); { std::unique_lock ul(m); @@ -345,9 +343,6 @@ MultiLinePlot JFJochReceiverPlots::GetPlots(const PlotRequest &request) { case PlotType::ImageScaleCC: ret = image_scale_cc.GetMeanPlot(nbins, start, incr, request.fill_value); break; - case PlotType::ImageScaleBFactor: - ret = image_scale_b.GetMeanPlot(nbins, start, incr, request.fill_value); - break; case PlotType::ImageScaleFactor: ret = image_scale_factor.GetMeanPlot(nbins, start, incr, request.fill_value); break; @@ -575,9 +570,6 @@ void JFJochReceiverPlots::GetPlotRaw(std::vector &v, PlotType type, const case PlotType::ImageScaleFactor: v = image_scale_factor.ExportArray(); break; - case PlotType::ImageScaleBFactor: - v = image_scale_b.ExportArray(); - break; case PlotType::CompressionRatio: v = compression_ratio.ExportArray(); break; diff --git a/common/JFJochReceiverPlots.h b/common/JFJochReceiverPlots.h index 01b7c17c..4af96d24 100644 --- a/common/JFJochReceiverPlots.h +++ b/common/JFJochReceiverPlots.h @@ -70,7 +70,6 @@ class JFJochReceiverPlots { StatusVector integrated_reflections; StatusVector image_scale_factor; StatusVector image_scale_cc; - StatusVector image_scale_b; StatusVector compression_ratio; // StatusVector objects are fully thread-safe (protected by internal mutex) diff --git a/common/Plot.h b/common/Plot.h index 4ecbe1c1..b629c1b4 100644 --- a/common/Plot.h +++ b/common/Plot.h @@ -15,7 +15,7 @@ enum class PlotType { ROISum, ROIMean, ROIMaxCount, ROIPixels, ROIWeightedX, ROIWeightedY, PacketsReceived, MaxValue, ResolutionEstimate, ProfileRadius, Mosaicity, BFactor, PixelSum, StrongPixels, RefinementBeamX, RefinementBeamY, ImageProcessingTime, IntegratedReflections, - ImageScaleFactor, ImageScaleCC, ImageScaleBFactor, CompressionRatio, IndexingLatticeCount, IceRingScore + ImageScaleFactor, ImageScaleCC, CompressionRatio, IndexingLatticeCount, IceRingScore }; enum class PlotAzintUnit { diff --git a/common/ScalingSettings.cpp b/common/ScalingSettings.cpp index fe7a8952..9a43f393 100644 --- a/common/ScalingSettings.cpp +++ b/common/ScalingSettings.cpp @@ -5,11 +5,6 @@ #include "ScalingSettings.h" -ScalingSettings& ScalingSettings::RefineB(bool input) { - refine_b = input; - return *this; -} - ScalingSettings& ScalingSettings::MergeFriedel(bool input) { merge_friedel = input; return *this; @@ -29,10 +24,6 @@ ScalingSettings& ScalingSettings::HighResolutionLimit_A(std::optional li return *this; } -bool ScalingSettings::GetRefineB() const { - return refine_b; -} - bool ScalingSettings::GetMergeFriedel() const { return merge_friedel; } @@ -50,14 +41,6 @@ std::optional ScalingSettings::GetHighResolutionLimit_A() const { return high_resolution_limit_A; } -double ScalingSettings::GetMinB() const { - return min_b; -} - -double ScalingSettings::GetMaxB() const { - return max_b; -} - double ScalingSettings::GetMinMosaicity() const { return 0.001; } diff --git a/common/ScalingSettings.h b/common/ScalingSettings.h index b372e00b..abef9dfa 100644 --- a/common/ScalingSettings.h +++ b/common/ScalingSettings.h @@ -12,10 +12,6 @@ enum class ResolutionCutoffMethod { Off, CCHalfLogistic }; class ScalingSettings { - bool refine_b = false; - double max_b = 200.0; - double min_b = -50.0; - bool refine_wedge = false; bool merge_friedel = true; @@ -86,7 +82,6 @@ class ScalingSettings { bool scaling_regularize = false; public: - ScalingSettings& RefineB(bool input); ScalingSettings& RefineRotationWedge(bool input); ScalingSettings& RotationWedgeForScaling(std::optional input); ScalingSettings& MergeFriedel(bool input); @@ -113,12 +108,8 @@ public: ScalingSettings& ResolutionCCTarget(double input); ScalingSettings& ReportShellCount(int input); - [[nodiscard]] bool GetRefineB() const; [[nodiscard]] bool GetRefineWedge() const; - [[nodiscard]] double GetMinB() const; - [[nodiscard]] double GetMaxB() const; - [[nodiscard]] double GetMinMosaicity() const; [[nodiscard]] double GetDefaultMosaicity() const; [[nodiscard]] double GetMaxMosaicity() const; diff --git a/docs/CBOR.md b/docs/CBOR.md index 5f3fd2c5..0dbcd207 100644 --- a/docs/CBOR.md +++ b/docs/CBOR.md @@ -220,7 +220,6 @@ See [DECTRIS documentation](https://github.com/dectris/documentation/tree/main/s | beam_corr_y | float | Beam center correction Y applied during processing \[pixel\] | | X | | image_scale_factor | float | Scaling result: Image scale factor (g) | | X | | image_scale_mosaicity | float | Scaling result: Image scale mosaicity \[deg\] | | X | -| image_scale_b_factor | float | Scaling result: Image scale B factor \[Angstrom^2\] | | X | | image_scale_cc | float | Scaling result: Image scale CC | | X | | adu_histogram | Array(uint64) | ADU histogram | | | | roi_integrals | object | Results of ROI calculation | | X | @@ -321,7 +320,6 @@ See [DECTRIS documentation](https://github.com/dectris/documentation/tree/main/s | niggli_class | Array(uint8) | Per-image Niggli class identifier for indexed images; 0 if unavailable | | | pixel_sum | Array(int64) | Per-image sum of all valid pixels, excluding error/saturated pixels | | | image_scale_mosaicity | Array(float) | Scaling result: Image scale mosaicity \[deg\] | | -| image_scale_b_factor | Array(float) | Scaling result: Image scale B factor \[Angstrom^2\] | | | image_scale_cc | Array(float) | Scaling result: Image scale CC | | End-message vector fields are optional. When present, they provide master-file summary data so readers can inspect scan-level and per-image analysis results without opening every linked data file. Missing optional per-image values are encoded by the producer as zero unless otherwise noted. diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index c284ac71..234dfff2 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -187,7 +187,7 @@ Special cases: ### 3.2 Adaptive (self-calibrating) detection -The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. An optional **adaptive** mode (`--adaptive-spots`) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. +The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. The **adaptive** mode (`--adaptive-spots`; the default for stills in `rugnux` and in the viewer, `--no-adaptive-spots` reverts) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. Rotation data keeps the fixed-threshold finder by default: across a 33-crystal rotation battery adaptive detection helped four hard crystals but deterministically broke three (a lost space group, a halved indexing rate, a collapsed merge). Pixels are binned into the same resolution rings as the azimuthal integrator (§2). For each ring a robust background is estimated in three passes: one plain pass over all valid pixels, then two $\sigma$-clipping passes that keep only pixels within $\pm 3\sigma$ of the current ring mean (removing the Bragg peaks from the background estimate). This yields a per-ring background mean $\mu_b$ and scatter $\sigma_b$. @@ -197,6 +197,8 @@ t_b = \max\!\big(\;\mu_b + z\,\sqrt{\sigma_b^2 + \sigma_\mathrm{read}^2}\;,\;\; $ where $k_\mathrm{Poisson}(\mu_b,p)$ is the smallest count whose Poisson$(\mu_b)$ upper tail is $\le p$. The Poisson arm is correct where the background is countable (a bright low-resolution ring gets a high threshold); the Gaussian arm — floored by a detector-level excess-noise constant $\sigma_\mathrm{read}$ — takes over on near-empty high-resolution rings, where the Poisson arm degenerates to "one photon is significant" and would flood. The operating point $p = E/N$ is set from a single portable knob $E$, the expected number of false pixels tolerated per frame (`--spot-false-pixels`, default 100), with $N$ the number of valid pixels. Because $p$ and every $\mu_b,\sigma_b$ come from the image itself, the same $E$ lands a sensible photon threshold on strong and weak datasets alike, with no per-dataset tuning. Rings too sparse to characterise (fewer than ~40 pixels) fall back to a whole-frame background. A pixel is strong when $v_i \ge t_b$ for its ring (saturated pixels are still forced strong); the strong pixels then feed the same CCL stage (§3.4). The signal-to-noise and photon-count criteria of §3.1 are not used in this mode. +Because detection reads the pixel's ring, a pixel that falls outside the azimuthal-integration $q$ range has no ring and can never be strong: the integration range bounds what adaptive detection can see. Both upper limits are therefore optional and default to the detector itself — the azimuthal integration runs to the highest $q$ any pixel of the detector reaches (`--azim-max-q` unset), and for stills, spot finding is not clipped in resolution (`--spot-high-resolution` unset). Setting either one narrows detection accordingly. Rotation data keeps a 1.5 Å spot-finding limit by default: the extra high-resolution spots the detector-edge limit admits are mostly noise there and cost indexing (measured 100.0 → 96.8 % on a strong rotation set). + **Fused GPU engine.** The per-ring reduction the adaptive threshold needs is the *same* reduction the azimuthal integrator performs. On the GPU path the two are fused into a single image pass (`AdaptiveSpotFinderGPU`): one reduction accumulates the corrected per-ring sums for the azimuthal profile (§2) *and* the raw per-ring statistics for the threshold, after which a light kernel flags the strong pixels. This replaces both the separate azimuthal-integration pass and the host-side adaptive spot-finding pass with one GPU pass — on a ~4.5 MP detector it runs in ~1 ms/frame versus ~40 ms for the CPU adaptive finder, and produces an identical spot list and azimuthal profile. It is enabled by default in the offline `rugnux` path and the interactive viewer; the online receiver keeps the CPU adaptive finder. ### 3.3 Resolution and ice-ring handling @@ -225,7 +227,7 @@ The minimum-pixels-per-spot filter (§3.4) trades sensitivity against noise: a s $$ \frac{n_\mathrm{indexed}^2}{n_\mathrm{total}} \quad\text{(indexed-spot count weighted by indexed fraction)} $$ -is kept; the frame is then integrated once at that min-pix. The fraction factor discounts the extra spots a smaller min-pix admits *unless the lattice actually explains them*, so strong frames keep their real weak spots (extending resolution) while noise-flooded frames stay strict. Because min-pix filters the connected components *after* detection, the three attempts only repeat the cheap CCL and spot-level filter, not the pixel reduction; the azimuthal profile is identical across them. This is a **stills-only, indexing-path** option — rotation indexing builds one global lattice from all frames and keeps a fixed min-pix. In `rugnux` it is the default; giving an explicit `--min-pix-per-spot` pins a fixed value instead. +is kept; the frame is then integrated once at that min-pix. The fraction factor discounts the extra spots a smaller min-pix admits *unless the lattice actually explains them*, so strong frames keep their real weak spots (extending resolution) while noise-flooded frames stay strict. Because min-pix filters the connected components *after* detection, strong-pixel detection runs **once** per frame and the three attempts only repeat the cheap CCL and spot-level filter, not the pixel reduction; the azimuthal profile is the one that single detection pass computed. The winning attempt's spot list is kept rather than re-extracted, so the frame that is integrated is exactly the frame that was scored. This is a **stills-only, indexing-path** option — rotation indexing builds one global lattice from all frames and keeps a fixed min-pix. In `rugnux` it is the default; giving an explicit `--min-pix-per-spot` pins a fixed value instead. --- @@ -546,7 +548,7 @@ The partiality applied is fixed by the data type and scaling stage, not chosen f 2. **Unity** ($P_{ij}=1$): used for the scale-on-fulls refit (§10.6), where each observation is already a complete reflection. -3. **Fixed**: use the per-reflection partiality carried from prediction. Still/serial images are predicted with $P=1$ by default, so their scaling is effectively unity/fixed. An optional excitation-error still-partiality model (`--still-partiality`) instead weights each stills reflection by a Gaussian $\exp(-\Delta_\mathrm{Ewald}^2/2\sigma^2)$ in its distance from the Ewald sphere, with a companion merge term (`--partiality-uncertainty`) that adds an intensity- and $(1-P)$-proportional sigma to down-weight the least-complete reflections. +3. **Fixed**: use the per-reflection partiality carried from prediction. Still/serial images are predicted with $P=1$, so a single-pass stills scale is effectively unity/fixed — which is exactly what `--simple-stills` keeps. By default the stills path instead **post-refines a physical partiality**: a small per-crystal orientation tilt $(\delta\psi_x,\delta\psi_y)$ about the two axes perpendicular to the beam is refined against the running merge, and every reflection's partiality is then recomputed analytically from the refined lattice through its excitation error $\Delta_\mathrm{Ewald}=\big|\,|\mathbf{q}+\mathbf{S}_0|-1/\lambda\,\big|$ and an angular width. A tilt moves reflections on opposite sides of the Ewald sphere in opposite directions, so it reshapes the *spatial* pattern of partialities in a way the per-image scale $G$ cannot mimic — which is why it succeeds where a freely-fitted scalar partiality width simply collapses into $G$. Nothing is re-integrated (the integrated intensities are fixed); the tilt is hard-bounded at about 1° and held by a soft prior, so it stays inert on sparse or weak crystals. The cycle is merge → per-crystal tilt refinement (with $G$ profiled out by the same robust Cauchy IRLS used for the per-frame scales, §10.3) → recompute $P$ → re-merge, repeated a few times. Reflections below a minimum partiality can be rejected from merging to avoid unstable corrections. @@ -568,6 +570,8 @@ I_h = \frac{\sum_j w_j I^{\mathrm{corr}}_{ij}}{\sum_j w_j},\qquad w_j = \frac{1}{(\sigma^{\mathrm{corr}}_{ij})^2}. $ +The weights use an **expected** variance: the Poisson signal part of each $\sigma^{\mathrm{corr}}_{ij}$ is rebuilt at the reflection's merged $\langle I\rangle$ rather than at that observation's own intensity. Weighting by an observation's own $\sigma^2$ biases the inverse-variance mean low below about one photon, because an up-fluctuated observation gets a larger sigma and is then down-weighted too hard. The rotation combine already does this; for stills it is on by default, and `--no-expected-variance-merge` restores the observed-sigma weighting. + An internal-consistency term can inflate uncertainties when multiple observations are present, in the spirit of XSCALE. ### 10.5 Merging statistics @@ -600,7 +604,7 @@ After scale-fulls, three **correction surfaces** are fitted on the combined full - **Decay.** Radiation damage weakens later frames more at higher resolution — a resolution×time (Debye–Waller) systematic the resolution-flat per-image scale cannot capture. A single global relative-$B$ rate is fitted, $\ln(I_\mathrm{ref}/I_\mathrm{obs}) = 2\,(\mathrm{d}B/\mathrm{d}n)\,(n-\bar n)\,s^2$ (frame $n$, $s^2 = 1/4d^2$), and folded into the scale. It engages only when the total relative-$B$ over the run exceeds a physical floor (2 Ų); below that the decay is negligible and "correcting" it only spreads symmetry equivalents (same $s^2$, different frames). An optional **per-batch relative-$B$** (`--relative-b[=deg]`, off unless requested; 10°-of-rotation batches by default) extends the single global rate to a smooth $B(n)$ curve — the same $s^2$-weighted decay fit solved independently over short frame batches, curvature-penalized so it cannot over-fit and cross-validated like the surfaces below — for crystals whose decay is non-linear in dose. - **Absorption.** A smooth multiplicative factor over the diffracted-beam direction expressed in the goniometer (crystal) frame: each full's predicted detector position gives the lab diffracted direction, de-rotated by the spindle so a fixed crystal-frame direction is sampled at many rotation angles and its grid cell is well-determined. Negligible at hard X-rays / thin crystals; it matters at low photon energy. -- **Modulation** (detector-plane flat-field). A smooth multiplicative factor over where each reflection lands on the detector (predicted $x,y$): symmetry-equivalents land at different positions as the crystal rotates, over-determining the surface. It absorbs detector-response and geometric systematics that inflate $R_\mathrm{meas}$. The same 16×16 detector-frame surface is available for the stills path (`--stills-modulation`, off by default), where serial data repeatedly hammers the same detector regions. +- **Modulation** (detector-plane flat-field). A smooth multiplicative factor over where each reflection lands on the detector (predicted $x,y$): symmetry-equivalents land at different positions as the crystal rotates, over-determining the surface. It absorbs detector-response and geometric systematics that inflate $R_\mathrm{meas}$. Each surface is **cross-validated**: fitted on even-numbered frames and kept only if it improves the held-out odd-frame agreement by a clear margin (and vice versa), scored by a **σ-independent, $R_\mathrm{meas}$-like** fractional agreement $\sum|I_s-I_\mathrm{ref}|/\sum|I_\mathrm{ref}|$ rather than a studentized $\chi^2$ — so a surface cannot "pass" by reshaping the sigmas instead of tightening the intensities. A surface fitted to noise where its systematic is absent does not generalize and is discarded — a correction never adds scatter. @@ -695,10 +699,10 @@ A **dataset-wide** Wilson $B$ is also estimated over the merged reflections — - **Bragg integration is profile-fitted by default** (per-shell Gaussian profile, Kabsch extraction; §9.3), with plain box summation available as a fallback (`--integrator boxsum`). The profiles are built per frame from that frame's strong spots, which suits fast-feedback and serial/streaming use; a profile shared across many frames (as in full offline workflows) is not currently formed. - **Space-group symmetry** beyond centering absences is not necessarily enforced during prediction/integration unless the space group is supplied and used downstream. - **Resolution masking and ice rings** are controllable; including ice-ring spots in indexing can improve robustness for some samples but may bias refinement in others. -- **Rotation vs still modes** differ substantially in prediction and scaling: partiality is angle-driven in rotation data, while stills are predicted (within an excitation-error window) and scaled with unit partiality. +- **Rotation vs still modes** differ substantially in prediction and scaling: partiality is angle-driven in rotation data, while stills are predicted within an excitation-error window and get their partiality from the default-on per-crystal tilt post-refinement (§10.2) — or unit partiality with `--simple-stills`. - **Space-group determination.** When no space group is supplied, a POINTLESS-like search scores Laue-group symmetry (CC of $I(h)$ vs $I(Rh)$ plus merge self-consistency) and detects screw/centering absences from the $P1$-merged intensities. The self-consistency test is calibrated so a merohedral twin — whose twin law forces non-equivalent reflections together and inflates the merged $\chi^2$ — stays in its true lower symmetry rather than being over-promoted to the holohedral group. Because a partial twin's within-orbit $\chi^2$ can nonetheless look self-consistent, a chi²-passing promotion is additionally **vetoed** when merging its extra operator balloons the error-model $b$ (the intensity-proportional systematic) relative to the confirmed subgroup: a genuine symmetry step gains multiplicity without inflating $b$, whereas a twin forces non-equivalent reflections together and $b$ balloons. **Centering** is accepted when the systematically-absent class is weak relative to the present one by *either* of two floor-independent tests — its mean signed $I/\sigma$ well below the present mean, *or* its rate of individually-significant reflections well below the present class's own significant rate. The second test matters on weak / low-energy data, where a positive intensity floor (background/profile leakage) lifts the absent class's mean $I/\sigma$ to $\sim1.5$–$2.3$ instead of $\sim0$ and, when the present class is itself weak, inflates the plain mean ratio past its bound and hides a real centering (an $I$-centred cubic recorded at 5 keV was otherwise kept primitive); a false centering fails both tests because its absent class is as strong as the present one. When several centerings pass, they are ranked by their **net** systematic absences (absent minus violating), not the gross absent count, so a super-centering (e.g. $F$ over a true $C$) whose extra, only-half-populated absent class merely dilutes the strength ratio does not out-rank the correct lower centering. - **Twinning check.** A Padilla–Yeates $L$-test ($\langle|L|\rangle$, $\langle L^2\rangle$) and the second moment $\langle I^2\rangle/\langle I\rangle^2$ (taken per resolution shell with noise-only shells skipped and Wilson outliers rejected, so a single strong reflection in a collapsed-mean shell cannot skew it) are written to the merged mmCIF as a twinning diagnostic. Twinning is only flagged in Laue classes where a merohedral twin law can exist; the holohedral high-symmetry classes ($4/mmm$, $6/mmm$, $m\bar{3}m$, and $\bar{3}m$ on a rhombohedral lattice) are exempt, so a low $\langle|L|\rangle$ there is reported as a statistical artefact rather than twinning. -- **Outlier rejection.** Merging applies an optional per-observation median-based $N\sigma$ cut (default 6σ for `rot3d`) and an optional per-crystal $\Delta\mathrm{CC}_{1/2}$ image rejection (`--reject-delta-cchalf`, CrystFEL-style, off by default). The same $N\sigma$ cut is fed back into the error model: after an initial $a,b$ fit the parameters are re-fit once on the reflections that survive rejection (dropping any whose squared deviation exceeds $N\sigma^2\,[a\,\sigma^2 + (b\,\langle I\rangle)^2]$), so the calibrated errors describe the reflections that actually enter the merge rather than the pre-rejection pool. +- **Outlier rejection.** Merging applies an optional per-observation median-based $N\sigma$ cut (`--reject-outliers`, default 6σ for `rot3d`, off otherwise). The same $N\sigma$ cut is fed back into the error model: after an initial $a,b$ fit the parameters are re-fit once on the reflections that survive rejection (dropping any whose squared deviation exceeds $N\sigma^2\,[a\,\sigma^2 + (b\,\langle I\rangle)^2]$), so the calibrated errors describe the reflections that actually enter the merge rather than the pre-rejection pool. - **Automatic resolution cutoff.** By default the reported/written high-resolution limit is trimmed where $\mathrm{CC}_{1/2}$ falls off (logistic, target 0.30); `--scaling-high-resolution` overrides it and `--resolution-cutoff off` disables it. - **Amplitudes and intensities.** The merged output carries both intensities (mmCIF `intensity_meas`, MTZ `IMEAN`/`SIGIMEAN`) and French–Wilson amplitudes (mmCIF `F_meas_au`, MTZ `F`/`SIGF`; §10.8), so a downstream program can refine against either. diff --git a/docs/HDF5.md b/docs/HDF5.md index e561730e..2d095956 100644 --- a/docs/HDF5.md +++ b/docs/HDF5.md @@ -261,7 +261,6 @@ In legacy/VDS mode these live in the data files and are linked/virtual-stacked i | `imageScaleFactor` | | on-the-fly per-image scale factor *g* | | `imageScaleCC` | | on-the-fly scaling correlation coefficient | | `imageScaleMosaicity` | deg | scaling-model mosaicity | -| `imageScaleBFactor` | Ų | scaling-model B-factor | **Per-image lattices:** `latticeIndexed` `[n_images, 9]` (Å) — the real-space lattice (flattened 3×3); `latticeIndexedExtra` `[n_images, max_extra_lattices, 9]` (Å) — additional orientation diff --git a/docs/RUGNUX.md b/docs/RUGNUX.md index 68075a24..5bb47bff 100644 --- a/docs/RUGNUX.md +++ b/docs/RUGNUX.md @@ -150,14 +150,13 @@ then merge against a reference structure: rugnux serial_master.h5 \ -o serial_run -N 32 \ -X ffbidx -C 79,79,38,90,90,90 -S 96 \ - --adaptive-spots \ -z reference.mtz \ --scaling-high-resolution 1.8 ``` -`ffbidx` requires a known cell (`-C`) and is the indexer of choice for sparse serial stills. For -serial stills, prefer the self-calibrating spot finder (`--adaptive-spots`) and leave -`--min-pix-per-spot` **unset** so it is chosen per image — across the still-target battery this +`ffbidx` requires a known cell (`-C`) and is the indexer of choice for sparse serial stills. The +self-calibrating spot finder is on by default for stills (`--no-adaptive-spots` turns it off), and for +serial stills leave `--min-pix-per-spot` **unset** so it is chosen per image — across the still-target battery this combination raises the indexing rate and typically extends resolution over a fixed threshold and fixed min-pix, at equal or better CC½. (You can still pin a fixed threshold with `--spot-sigma` / `--spot-threshold` and a fixed min-pix with `--min-pix-per-spot`.) If a dataset *does* carry a @@ -189,9 +188,10 @@ Spot finding: | --- | --- | | `--spot-sigma ` | Noise sigma level for spot finding (default: 3.0) | | `--spot-threshold ` | Photon-count threshold for spot finding (default: 10) | -| `--adaptive-spots` | Self-calibrating detection: replace the fixed `--spot-threshold` with a per-resolution-ring threshold derived from each image's own noise, so one setting adapts across datasets (no per-dataset `--spot-threshold`/`--spot-sigma` tuning) | +| `--adaptive-spots` | Self-calibrating detection (**default for stills**): the strong-pixel threshold comes from each image's own per-resolution-ring noise instead of the fixed `--spot-threshold`, so one setting adapts across datasets (no per-dataset `--spot-threshold`/`--spot-sigma` tuning). Rotation data keeps the fixed-threshold finder unless this is given | +| `--no-adaptive-spots` | Turn adaptive detection off and use the fixed `--spot-threshold` / `--spot-sigma` finder | | `--spot-false-pixels ` | Adaptive-detection operating point: expected noise pixels tolerated per frame (default: 100; implies `--adaptive-spots`) | -| `--spot-high-resolution ` | High-resolution limit for spot finding, Å (default: 1.5) | +| `--spot-high-resolution ` | High-resolution limit for spot finding, Å. Omitted: stills extend as far as the detector reaches (no resolution clipping); rotation data keeps a 1.5 Å limit | | `--spot-low-resolution ` | Low-resolution limit for spot finding, Å (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weak serial data) | | `--min-pix-per-spot ` | Minimum connected strong pixels per spot. **If omitted, min-pix is chosen per image** (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed-spot count × indexed fraction is kept. Give an explicit value to force a fixed min-pix instead. | | `--max-spots ` | Maximum spot count (default: 250) | @@ -203,7 +203,7 @@ Azimuthal integration (the radial profile behind the per-image ice-ring score): | --- | --- | | `-q, --azim-q-spacing ` | Q bin spacing, 1/Å (default: 0.01; finer resolves the narrow ice rings) | | `--azim-min-q ` | Minimum Q, 1/Å | -| `--azim-max-q ` | Maximum Q, 1/Å | +| `--azim-max-q ` | Maximum Q, 1/Å. Omitted: integration extends to the highest Q the detector reaches. The adaptive spot finder shares these Q bins, so this also sets how far self-calibrating detection can see | | `--azim-phi-bins ` | Number of azimuthal (phi) bins (default: 1) | | `--polarization-correction ` | Enable/disable the azimuthal polarization correction | | `--solid-angle-correction ` | Enable/disable the azimuthal solid-angle correction | @@ -239,14 +239,12 @@ Scaling and merging: | --- | --- | | `--no-merge` | Skip scaling and merging (on by default); write only the per-image `_process.h5` | | `-A, --anomalous` | Anomalous mode (keep Friedel pairs separate) | -| `-B, --refine-bfactor` | Refine a per-image B-factor (stills only) | | `--scale-fulls` / `--no-scale-fulls` | rot3d: refit a per-frame scale on the combined fulls (XDS order, Unity model); on by default for rotation data, off for stills | | `--smooth-g[=deg]` | rot3d: smooth the per-frame scale *G* over a degree range before the 3D combine (XDS DELPHI-like; default 5° for rotation, 0 = off) | | `--no-scaling-corrections` | rot3d: disable the default-on decay + absorption + modulation correction surfaces fitted on the fulls after scale-fulls (see below) | | `--relative-b[=deg]` | rot3d: fit a per-batch relative-*B* beyond the single decay slope over deg-degree batches, cross-validated (default 10° when bare; off otherwise) | -| `--still-partiality` | Experimental (stills): weight reflections by a Gaussian excitation-error partiality instead of treating each as a full | -| `--partiality-uncertainty ` | Stills: extra merge sigma ~num·(1−partiality)·⟨I⟩ on partials (use with `--still-partiality`; default 0, ~2.5 recommended) | -| `--stills-modulation` | Experimental (stills): fit a detector-plane modulation (flat-field) surface, cross-validated (default off) | +| `--simple-stills` | Stills: treat every reflection as a full (*p* = 1, single-pass scale/merge) — disables the default-on physical partiality post-refinement | +| `--no-expected-variance-merge` | Stills: disable the default expected-variance merge weighting (which rebuilds each weak observation's signal variance at the reflection mean to de-bias the inverse-variance merge); restores observed-sigma weighting | | `--capture-uncertainty ` | rot3d: systematic sigma on under-captured fulls, ~num·(1−captured_fraction)·I (default: 1.0 for rotation, 0 otherwise) | | `--min-captured-fraction ` | rot3d: drop a combined full whose rocking curve was captured below this fraction — edge-of-sweep truncated fulls (default: 0.7 for rotation, 0 otherwise; 0 = off) | | `--scaling-high-resolution ` | High-resolution limit for scaling, Å — manual override (default: no limit; disables the automatic cutoff below) | @@ -255,7 +253,6 @@ Scaling and merging: | `--resolution-shells ` | Number of resolution shells in the reported statistics table (default: 10) | | `--min-partiality ` | Minimum partiality to accept a reflection (default: 0.02) | | `--reject-outliers ` | Per-observation outlier rejection, N σ from the per-reflection median (default: 6 for `rot3d`, off otherwise) | -| `--reject-delta-cchalf ` | Drop images with ΔCC1/2 below mean − N·stddev (default: off) | | `--min-image-cc ` | Per-image CC limit, percent (default: no limit) | | `--mosaicity ` | Diagnostic: fix the scaling mosaicity (°) instead of using the per-image seed | | `--scaling-iterations ` | Scaling iterations with no reference data (default: 3) | diff --git a/fpga/pcie_driver/postinstall.sh b/fpga/pcie_driver/postinstall.sh index 6340c150..2ce71017 100644 --- a/fpga/pcie_driver/postinstall.sh +++ b/fpga/pcie_driver/postinstall.sh @@ -5,7 +5,7 @@ VERSION=1.0.0-rc.160 occurrences=`/usr/sbin/dkms status | grep jfjoch | grep ${VERSION} | wc -l` -if [ ! occurrences > 0 ]; then +if [ "$occurrences" -eq 0 ]; then /usr/sbin/dkms add -m jfjoch -v ${VERSION} fi /usr/sbin/dkms build -m jfjoch -v ${VERSION} diff --git a/frame_serialize/CBORStream2Deserializer.cpp b/frame_serialize/CBORStream2Deserializer.cpp index 9c36c8a8..14642a35 100644 --- a/frame_serialize/CBORStream2Deserializer.cpp +++ b/frame_serialize/CBORStream2Deserializer.cpp @@ -828,8 +828,6 @@ namespace { message.image_scale_cc = GetCBORFloat(value); else if (key == "image_scale_mosaicity") message.image_scale_mosaicity = GetCBORFloat(value); - else if (key == "image_scale_b_factor") - message.image_scale_b_factor = GetCBORFloat(value); else if (key == "roi_integrals") ProcessROIElementMap(message, value); else { @@ -1449,8 +1447,6 @@ namespace { GetCBORFloatArray(value, message.image_scale_cc); else if (key == "image_scale_mosaicity") GetCBORFloatArray(value, message.image_scale_mosaicity); - else if (key == "image_scale_b_factor") - GetCBORFloatArray(value, message.image_scale_b_factor); else if (key == "integrated_reflections") GetCBORInt32Array(value, message.integrated_reflections); else if (key == "niggli_class") diff --git a/frame_serialize/CBORStream2Serializer.cpp b/frame_serialize/CBORStream2Serializer.cpp index da5134fe..813bedf7 100644 --- a/frame_serialize/CBORStream2Serializer.cpp +++ b/frame_serialize/CBORStream2Serializer.cpp @@ -785,7 +785,6 @@ void CBORStream2Serializer::SerializeSequenceEnd(const EndMessage& message) { CBOR_ENC(mapEncoder, "image_scale_factor", message.image_scale_factor); CBOR_ENC(mapEncoder, "image_scale_cc", message.image_scale_cc); CBOR_ENC(mapEncoder, "image_scale_mosaicity", message.image_scale_mosaicity); - CBOR_ENC(mapEncoder, "image_scale_b_factor", message.image_scale_b_factor); CBOR_ENC(mapEncoder, "integrated_reflections", message.integrated_reflections); CBOR_ENC(mapEncoder, "niggli_class", message.niggli_class); CBOR_ENC(mapEncoder, "pixel_sum", message.pixel_sum); @@ -870,7 +869,6 @@ void CBORStream2Serializer::SerializeImageInternal(CborEncoder &mapEncoder, cons CBOR_ENC(mapEncoder, "beam_corr_y", message.beam_corr_y); CBOR_ENC(mapEncoder, "image_scale_factor", message.image_scale_factor); CBOR_ENC(mapEncoder, "image_scale_mosaicity", message.image_scale_mosaicity); - CBOR_ENC(mapEncoder, "image_scale_b_factor", message.image_scale_b_factor); CBOR_ENC(mapEncoder, "image_scale_cc", message.image_scale_cc); CBOR_ENC(mapEncoder, "user_data", message.user_data.dump()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02bf59d5..08759dc6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "jungfraujoch-frontend", - "version": "1.0.0-rc.159", + "version": "1.0.0-rc.160", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jungfraujoch-frontend", - "version": "1.0.0-rc.159", + "version": "1.0.0-rc.160", "license": "GPL-3.0", "dependencies": { "@emotion/react": "^11.10.4", diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index eba0e361..c2095aa0 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -712,9 +712,11 @@ export type spot_finding_settings = { min_pix_per_spot: number; max_pix_per_spot: number; /** - * High resolution limit for spot finding [Angstrom] + * High resolution limit for spot finding [Angstrom]. Optional: if omitted, spot finding extends + * as far as the detector reaches, i.e. the detection is not clipped in resolution. + * */ - high_resolution_limit: number; + high_resolution_limit?: number; /** * Low resolution limit for spot finding [Angstrom] */ @@ -752,7 +754,13 @@ export type azim_int_settings = { * Apply solid angle correction for azimuthal integration */ solid_angle_corr: boolean; - high_q_recipA: number; + /** + * Upper q limit of the azimuthal integration [1/Angstrom]. Optional: if omitted, the integration + * (and the adaptive spot detection that shares these q bins) extends to the highest q the + * detector reaches. + * + */ + high_q_recipA?: number; low_q_recipA: number; q_spacing: number; /** @@ -1741,7 +1749,6 @@ export const plot_type = { INTEGRATED_REFLECTIONS: 'integrated_reflections', IMAGE_SCALE_FACTOR: 'image_scale_factor', IMAGE_SCALE_CC: 'image_scale_cc', - IMAGE_SCALE_B: 'image_scale_b', COMPRESSION_RATIO: 'compression_ratio', ICE_RING_SCORE: 'ice_ring_score' } as const; @@ -3043,7 +3050,7 @@ export type getPreviewPlotData = { /** * Type of requested plot */ - type: 'bkg_estimate' | 'azint' | 'azint_1d' | 'spot_count' | 'spot_count_low_res' | 'spot_count_indexed' | 'spot_count_ice' | 'indexing_rate' | 'indexing_lattice_count' | 'indexing_unit_cell_length' | 'indexing_unit_cell_angle' | 'profile_radius' | 'mosaicity' | 'b_factor' | 'error_pixels' | 'saturated_pixels' | 'image_collection_efficiency' | 'receiver_delay' | 'receiver_free_send_buf' | 'strong_pixels' | 'roi_sum' | 'roi_mean' | 'roi_max_count' | 'roi_pixels' | 'roi_weighted_x' | 'roi_weighted_y' | 'packets_received' | 'max_pixel_value' | 'resolution_estimate' | 'pixel_sum' | 'processing_time' | 'beam_center_x' | 'beam_center_y' | 'integrated_reflections' | 'image_scale_factor' | 'image_scale_cc' | 'image_scale_b' | 'compression_ratio' | 'ice_ring_score'; + type: 'bkg_estimate' | 'azint' | 'azint_1d' | 'spot_count' | 'spot_count_low_res' | 'spot_count_indexed' | 'spot_count_ice' | 'indexing_rate' | 'indexing_lattice_count' | 'indexing_unit_cell_length' | 'indexing_unit_cell_angle' | 'profile_radius' | 'mosaicity' | 'b_factor' | 'error_pixels' | 'saturated_pixels' | 'image_collection_efficiency' | 'receiver_delay' | 'receiver_free_send_buf' | 'strong_pixels' | 'roi_sum' | 'roi_mean' | 'roi_max_count' | 'roi_pixels' | 'roi_weighted_x' | 'roi_weighted_y' | 'packets_received' | 'max_pixel_value' | 'resolution_estimate' | 'pixel_sum' | 'processing_time' | 'beam_center_x' | 'beam_center_y' | 'integrated_reflections' | 'image_scale_factor' | 'image_scale_cc' | 'compression_ratio' | 'ice_ring_score'; /** * Fill value for elements that were missed during data collection * @@ -3090,7 +3097,7 @@ export type getPreviewPlotBinData = { /** * Type of requested plot */ - type: 'bkg_estimate' | 'azint' | 'azint_1d' | 'spot_count' | 'spot_count_low_res' | 'spot_count_indexed' | 'spot_count_ice' | 'indexing_rate' | 'indexing_lattice_count' | 'indexing_unit_cell_length' | 'indexing_unit_cell_angle' | 'profile_radius' | 'mosaicity' | 'b_factor' | 'error_pixels' | 'saturated_pixels' | 'image_collection_efficiency' | 'receiver_delay' | 'receiver_free_send_buf' | 'strong_pixels' | 'roi_sum' | 'roi_mean' | 'roi_max_count' | 'roi_pixels' | 'roi_weighted_x' | 'roi_weighted_y' | 'packets_received' | 'max_pixel_value' | 'resolution_estimate' | 'pixel_sum' | 'processing_time' | 'beam_center_x' | 'beam_center_y' | 'integrated_reflections' | 'image_scale_factor' | 'image_scale_cc' | 'image_scale_b' | 'compression_ratio' | 'ice_ring_score'; + type: 'bkg_estimate' | 'azint' | 'azint_1d' | 'spot_count' | 'spot_count_low_res' | 'spot_count_indexed' | 'spot_count_ice' | 'indexing_rate' | 'indexing_lattice_count' | 'indexing_unit_cell_length' | 'indexing_unit_cell_angle' | 'profile_radius' | 'mosaicity' | 'b_factor' | 'error_pixels' | 'saturated_pixels' | 'image_collection_efficiency' | 'receiver_delay' | 'receiver_free_send_buf' | 'strong_pixels' | 'roi_sum' | 'roi_mean' | 'roi_max_count' | 'roi_pixels' | 'roi_weighted_x' | 'roi_weighted_y' | 'packets_received' | 'max_pixel_value' | 'resolution_estimate' | 'pixel_sum' | 'processing_time' | 'beam_center_x' | 'beam_center_y' | 'integrated_reflections' | 'image_scale_factor' | 'image_scale_cc' | 'compression_ratio' | 'ice_ring_score'; /** * Name of ROI for which plot is requested */ diff --git a/frontend/src/client/zod.gen.ts b/frontend/src/client/zod.gen.ts index e74daad1..2a71e523 100644 --- a/frontend/src/client/zod.gen.ts +++ b/frontend/src/client/zod.gen.ts @@ -300,7 +300,7 @@ export const zSpotFindingSettings = z.object({ photon_count_threshold: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), min_pix_per_spot: z.coerce.bigint().gte(BigInt(1)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), max_pix_per_spot: z.coerce.bigint().gte(BigInt(1)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), - high_resolution_limit: z.number(), + high_resolution_limit: z.number().optional(), low_resolution_limit: z.number(), high_resolution_limit_for_spot_count_low_res: z.number().gte(2).lte(8), quick_integration: z.boolean().default(false), @@ -311,7 +311,7 @@ export const zSpotFindingSettings = z.object({ export const zAzimIntSettings = z.object({ polarization_corr: z.boolean().default(true), solid_angle_corr: z.boolean().default(true), - high_q_recipA: z.number().gte(0.00002).lte(10), + high_q_recipA: z.number().gte(0.00002).lte(10).optional(), low_q_recipA: z.number().gte(0.00001).lte(10), q_spacing: z.number().gte(0.00001), azimuthal_bins: z.coerce.bigint().gte(BigInt(1)).lte(BigInt(512)).optional().default(BigInt(1)), @@ -840,7 +840,6 @@ export const zPlotType = z.enum([ 'integrated_reflections', 'image_scale_factor', 'image_scale_cc', - 'image_scale_b', 'compression_ratio', 'ice_ring_score' ]); @@ -1150,7 +1149,6 @@ export const zGetPreviewPlotQuery = z.object({ 'integrated_reflections', 'image_scale_factor', 'image_scale_cc', - 'image_scale_b', 'compression_ratio', 'ice_ring_score' ]), @@ -1206,7 +1204,6 @@ export const zGetPreviewPlotBinQuery = z.object({ 'integrated_reflections', 'image_scale_factor', 'image_scale_cc', - 'image_scale_b', 'compression_ratio', 'ice_ring_score' ]), diff --git a/frontend/src/components/AzIntSettings.tsx b/frontend/src/components/AzIntSettings.tsx index c84ca9fb..73beedd9 100644 --- a/frontend/src/components/AzIntSettings.tsx +++ b/frontend/src/components/AzIntSettings.tsx @@ -30,6 +30,7 @@ function AzIntSettings({s: serverS}: MyProps) { const [lowQError, setLowQError] = useState(false); const [highQError, setHighQError] = useState(false); const [qSpacingError, setQSpacingError] = useState(false); + const [highQ, setHighQ] = useState(5); const { submit, pending, snackbar } = useUpload(putConfigAzimIntMutation()); useEffect(() => { @@ -37,6 +38,7 @@ function AzIntSettings({s: serverS}: MyProps) { setS(serverS); setLastDownloadedS(serverS); setDownloadCounter(c => c + 1); + setHighQ(serverS.high_q_recipA ?? highQ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverS]); @@ -46,7 +48,8 @@ function AzIntSettings({s: serverS}: MyProps) { return ( submit(s)} - uploadDisabled={pending || (s.high_q_recipA <= s.low_q_recipA) || highQError || lowQError || qSpacingError} + uploadDisabled={pending || ((s.high_q_recipA !== undefined) && (s.high_q_recipA <= s.low_q_recipA)) + || highQError || lowQError || qSpacingError} snackbar={snackbar}> { + setHighQ(val); setS(prev => ({...prev, high_q_recipA: val})); setHighQError(err); }} fullWidth/> + ) => { + // No high Q means integrating out to the highest Q the detector reaches. + setS(prev => event.target.checked ? _.omit(prev, "high_q_recipA") + : {...prev, high_q_recipA: highQ}); + }} + />} label="High Q to detector edge"/> (default_spot_finding_settings); const [lastDownloadedS, setLastDownloadedS] = useState(default_spot_finding_settings); const [highResGap, setHighResGap] = useState(1.5); + const [highResLimit, setHighResLimit] = useState(2.5); // Only adopt the server copy when it actually changed, otherwise the // 1 s statistics poll would overwrite edits the user is making. @@ -42,6 +43,7 @@ function DataProcessingSettings({s: serverS, update}: MyProps) { setS(serverS); setLastDownloadedS(serverS); setHighResGap(serverS.high_res_gap_Q_recipA ?? highResGap); + setHighResLimit(serverS.high_resolution_limit ?? highResLimit); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverS]); @@ -64,8 +66,15 @@ function DataProcessingSettings({s: serverS, update}: MyProps) { const setLowResolutionLimit = (event: Event, newValue: number | number[]) => apply({...s, low_resolution_limit: newValue as number}); - const setHighResolutionLimit = (event: Event, newValue: number | number[]) => - apply({...s, high_resolution_limit: newValue as number}); + const setHighResolutionLimit = (event: Event, newValue: number | number[]) => { + const v = newValue as number; + setHighResLimit(v); + apply({...s, high_resolution_limit: v}); + }; + + // No high-resolution limit means spot finding goes as far as the detector reaches. + const autoHighResolutionLimitToggle = (event: ChangeEvent) => + apply({...s, high_resolution_limit: event.target.checked ? undefined : highResLimit}); const setHighResolutionLimitForCountingLowResSpots = (event: Event, newValue: number | number[]) => apply({...s, high_resolution_limit_for_spot_count_low_res: newValue as number}); @@ -131,9 +140,13 @@ function DataProcessingSettings({s: serverS, update}: MyProps) { valueLabelFormat={(value) => value.toFixed(1)} /> + + To detector edge High resolution limit [Å] - value.toFixed(1)} diff --git a/image_analysis/IndexAndRefine.cpp b/image_analysis/IndexAndRefine.cpp index 08956bec..3e5e6885 100644 --- a/image_analysis/IndexAndRefine.cpp +++ b/image_analysis/IndexAndRefine.cpp @@ -104,6 +104,7 @@ IndexAndRefine::IndexingOutcome IndexAndRefine::DetermineLatticeAndSymmetry(Data // already ordered non-ice-first, strongest-first (FilterSpotsByCount), so the prefix IS the seed. const float idx_tol = experiment.GetIndexingSettings().GetTolerance(); const float idx_tol_sq = idx_tol * idx_tol; + constexpr float SEED_STOP_FRACTION = 0.9f; // seed explained this well -> stop escalating IndexerResult indexer_result; bool any_executed = false; float best_frac = -1.0f; @@ -133,6 +134,11 @@ IndexAndRefine::IndexingOutcome IndexAndRefine::DetermineLatticeAndSymmetry(Data } const float frac = recip.empty() ? 0.0f : static_cast(n) / recip.size(); if (frac > best_frac) { best_frac = frac; indexer_result = std::move(res); } + // A lattice that already explains nearly the whole seed is kept whatever a larger seed + // returns: the winner is the highest explained FRACTION, and adding weaker spots almost + // always lowers it. Stop here - this is what keeps clean frames at one indexer call. + if (frac >= SEED_STOP_FRACTION) + break; } if (recip.size() < seed_cap) // already fed every available spot; a larger cap won't add any break; @@ -408,7 +414,6 @@ void IndexAndRefine::QuickPredictAndIntegrate(DataMessage &msg, .geom = outcome.experiment.GetDiffractionGeometry(), .latt = latt, .mosaicity_deg = mos_deg, - .image_scale_b_factor_Ang2 = msg.image_scale_b_factor, .image_scale_cc = msg.image_scale_cc, }; @@ -425,10 +430,6 @@ void IndexAndRefine::QuickPredictAndIntegrate(DataMessage &msg, .mosaicity_deg = std::fabs(mos_deg), // FWHM -> sigma; 0 when monochromatic, leaving the prediction unchanged. .bandwidth_sigma = experiment.GetBandwidthFWHM().value_or(0.0f) / 2.3548f, - // Experimental stills partiality (off by default): sigma = ewald_dist_cutoff/2 = the per-image - // profile radius, so a reflection at the acceptance edge (dist_ewald ~ 2*sigma) keeps p ~ exp(-2). - .still_partiality = experiment.GetBraggIntegrationSettings().GetStillPartiality(), - .profile_radius_recipA = ewald_dist_cutoff * 0.5f }; // Predict, then integrate with the selected integrator (box-sum or profile-fit). @@ -510,14 +511,13 @@ IndexAndRefine::DetermineRefineAnalyze(DataMessage &msg, const SpotFindingSettin return outcome; } -bool IndexAndRefine::ProcessImage(DataMessage &msg, +void IndexAndRefine::ProcessImage(DataMessage &msg, const SpotFindingSettings &spot_finding_settings, BraggPrediction &prediction, const BraggIntegrateFn &integrate) { auto outcome = DetermineRefineAnalyze(msg, spot_finding_settings); if (outcome && spot_finding_settings.quick_integration) QuickPredictAndIntegrate(msg, spot_finding_settings, prediction, integrate, *outcome); - return outcome.has_value(); } bool IndexAndRefine::IndexFrameOnly(DataMessage &msg, const SpotFindingSettings &spot_finding_settings) { diff --git a/image_analysis/IndexAndRefine.h b/image_analysis/IndexAndRefine.h index dda9a0d3..54d09b51 100644 --- a/image_analysis/IndexAndRefine.h +++ b/image_analysis/IndexAndRefine.h @@ -104,7 +104,7 @@ public: // Returns whether the frame indexed (a lattice was found and refined). Integration, when it runs, // is a further step gated on quick_integration. - bool ProcessImage(DataMessage &msg, const SpotFindingSettings &settings, + void ProcessImage(DataMessage &msg, const SpotFindingSettings &settings, BraggPrediction &prediction, const BraggIntegrateFn &integrate); // Index a single frame (no integration) with the current forced rotation lattice; used to score // first-pass sampling schemes on the real per-image path. Returns whether the frame indexed. diff --git a/image_analysis/IntegrationOutcome.h b/image_analysis/IntegrationOutcome.h index 3b9cbec6..75b2a09c 100644 --- a/image_analysis/IntegrationOutcome.h +++ b/image_analysis/IntegrationOutcome.h @@ -12,7 +12,6 @@ struct IntegrationOutcome { CrystalLattice latt; std::vector reflections; std::optional mosaicity_deg; - std::optional image_scale_b_factor_Ang2; std::optional image_scale_cc; std::optional image_scale_cc_n; std::optional image_scale_g; diff --git a/image_analysis/MXAnalysisWithoutFPGA.cpp b/image_analysis/MXAnalysisWithoutFPGA.cpp index 7d188a38..5070dbf8 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.cpp +++ b/image_analysis/MXAnalysisWithoutFPGA.cpp @@ -134,35 +134,53 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, && spot_finding_settings.indexing && !experiment.IsRotationIndexing(); if (adaptive_min_pix) { - // Choose the per-image min-pix adaptively instead of a fixed one. min-pix filters - // connected components AFTER detection, so re-running the finder only re-does the cheap CCL + - // spot filter, not the reduction; the azimuthal profile is identical across attempts. Index - // at 3/2/1 (index-only, no integration/accumulation) and keep whichever maximises - // n_indexed^2 / n_total (indexed count weighted by indexed fraction), then integrate once at - // that min-pix. spot_finding_time_s covers the whole escalation. - const auto start_time = std::chrono::steady_clock::now(); + // Choose the per-image min-pix adaptively instead of a fixed one. min-pix filters connected + // components AFTER detection, so detection (the expensive per-pixel pass) runs ONCE and only + // the cheap CCL + spot filter is repeated; the azimuthal profile is the one Detect() computed. + // Index at 3/2/1 (index-only, no integration/accumulation), keep whichever maximises + // n_indexed^2 / n_total (indexed count weighted by indexed fraction) together with its spot + // list, and integrate that one. Keeping the list also means the frame integrated is exactly + // the frame scored, which re-extracting could not guarantee on the GPU (atomic-order sums). + const auto detect_start_time = std::chrono::steady_clock::now(); + finder.Detect(*preprocessor_buffer, spot_finding_settings); + float spot_finding_time_s = + std::chrono::duration(std::chrono::steady_clock::now() - detect_start_time).count(); + float indexing_time_s = 0.0f; + SpotFindingSettings s = spot_finding_settings; + std::vector best_spots; int best_mp = 0; double best_score = -1.0; for (int mp : {3, 2, 1}) { s.min_pix_per_spot = mp; - const std::vector spots = finder.Run(*preprocessor_buffer, s, mask_resolution); + const auto extract_start_time = std::chrono::steady_clock::now(); + std::vector spots = finder.ExtractSpots(*preprocessor_buffer, s, mask_resolution); + spot_finding_time_s += + std::chrono::duration(std::chrono::steady_clock::now() - extract_start_time).count(); SpotAnalyze(experiment, s, spots, output); - if (indexer.IndexFrameOnly(output, s)) { + const bool indexed = indexer.IndexFrameOnly(output, s); + indexing_time_s += output.indexing_time_s.value_or(0.0f); + if (indexed) { const double n_idx = static_cast(output.spot_count_indexed.value_or(0)); const double n_tot = static_cast(std::max(1, output.spot_count.value_or(1))); const double score = n_idx * n_idx / n_tot; - if (score > best_score) { best_score = score; best_mp = mp; } + if (score > best_score) { + best_score = score; + best_mp = mp; + best_spots = std::move(spots); + } } } if (best_mp != 0) { - // Re-run spot finding + index at the winning min-pix and integrate there. + // Index and integrate the winning spot list; no spot finding left to do. s.min_pix_per_spot = best_mp; - const std::vector spots = finder.Run(*preprocessor_buffer, s, mask_resolution); - SpotAnalyze(experiment, s, spots, output); + SpotAnalyze(experiment, s, best_spots, output); indexer.ProcessImage(output, s, *prediction, integrate_fn); + indexing_time_s += output.indexing_time_s.value_or(0.0f); } - output.spot_finding_time_s = std::chrono::duration(std::chrono::steady_clock::now() - start_time).count(); + // Each indexer call reports only its own time, so the escalation's total is summed here. + output.spot_finding_time_s = spot_finding_time_s; + output.indexing_time_s = indexing_time_s; } else { const auto spot_finding_start_time = std::chrono::steady_clock::now(); const std::vector spots = finder.Run(*preprocessor_buffer, spot_finding_settings, mask_resolution); @@ -174,8 +192,8 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output, #ifdef JFJOCH_USE_CUDA if (fused) { - // Lift the azimuthal profile the fused engine computed in the same pass (identical across - // any min-pix retries); its azint cost is folded into spot_finding_time_s above. + // Lift the azimuthal profile the fused engine computed in the same detection pass; its azint + // cost is folded into spot_finding_time_s above. profile.Clear(integration); profile += fused_adaptive->GetProfile(); output.azint_time_s = 0.0f; @@ -230,7 +248,10 @@ void MXAnalysisWithoutFPGA::RunROIOnly(DataMessage &output) { void MXAnalysisWithoutFPGA::UpdateMaskResolution(const SpotFindingSettings &settings) { mask_low_res = settings.low_resolution_limit; mask_high_res = settings.high_resolution_limit; + // No high-resolution limit requested -> mask nothing at the high-resolution end: no pixel has d < 0, + // and the detector's own edge is where the pixels stop anyway. + const float high_res = mask_high_res.value_or(0.0f); auto const &resolution_map = integration.Resolution(); for (int i = 0; i < mask_resolution.size(); i++) - mask_resolution[i] = (resolution_map[i] > mask_low_res) || (resolution_map[i] < mask_high_res); + mask_resolution[i] = (resolution_map[i] > mask_low_res) || (resolution_map[i] < high_res); } diff --git a/image_analysis/MXAnalysisWithoutFPGA.h b/image_analysis/MXAnalysisWithoutFPGA.h index c79f473a..64eb08f7 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.h +++ b/image_analysis/MXAnalysisWithoutFPGA.h @@ -54,7 +54,9 @@ class MXAnalysisWithoutFPGA { const PixelMask &mask; std::vector mask_resolution; - float mask_high_res; + // The limits mask_resolution was built for. Kept as the OPTIONAL the caller passed, so an unset + // high-resolution limit compares equal to itself and the mask is not rebuilt on every image. + std::optional mask_high_res; float mask_low_res; void UpdateMaskResolution(const SpotFindingSettings& settings); #ifdef JFJOCH_USE_CUDA diff --git a/image_analysis/bragg_prediction/BraggPrediction.cpp b/image_analysis/bragg_prediction/BraggPrediction.cpp index e19d0249..ac483c60 100644 --- a/image_analysis/bragg_prediction/BraggPrediction.cpp +++ b/image_analysis/bragg_prediction/BraggPrediction.cpp @@ -156,13 +156,6 @@ int BraggPrediction::Calc(const DiffractionExperiment &experiment, const Crystal continue; float d = 1.0f / sqrtf(recip_sq); - float partiality = 1.0f; - if (settings.still_partiality && settings.profile_radius_recipA > 0.0f) { - const float sig_bw = settings.bandwidth_sigma * std::fabs(recip_z); - const float sigma2 = settings.profile_radius_recipA * settings.profile_radius_recipA - + sig_bw * sig_bw; - partiality = std::exp(-0.5f * dist_ewald_sphere * dist_ewald_sphere / sigma2); - } reflections[i] = Reflection{ .h = h, .k = k, @@ -175,7 +168,7 @@ int BraggPrediction::Calc(const DiffractionExperiment &experiment, const Crystal .d = d, .dist_ewald = dist_ewald_sphere, .rlp = 1.0, - .partiality = partiality, + .partiality = 1.0f, .zeta = 1.0, .image_scale_corr = 1.0 }; diff --git a/image_analysis/bragg_prediction/BraggPrediction.h b/image_analysis/bragg_prediction/BraggPrediction.h index 5406d23e..60400b00 100644 --- a/image_analysis/bragg_prediction/BraggPrediction.h +++ b/image_analysis/bragg_prediction/BraggPrediction.h @@ -23,13 +23,6 @@ struct BraggPredictionSettings { // σ_bw = |recip_z|·bandwidth_sigma (= bλ/2d²), so the 1/d² pink-beam smear no // longer clips high-resolution reflections. float bandwidth_sigma = 0.0f; - // Experimental stills partiality (rugnux --still-partiality). When still_partiality is set and - // profile_radius_recipA > 0, each reflection gets a Gaussian excitation-error partiality - // p = exp(-dist_ewald^2 / (2*sigma^2)), sigma^2 = profile_radius_recipA^2 + (bandwidth_sigma*|recip_z|)^2, - // instead of the fixed 1.0 (full). Off by default. Keep these two as the trailing members so the - // existing designated initializers (which stop at bandwidth_sigma) remain valid. - bool still_partiality = false; - float profile_radius_recipA = 0.0f; }; class BraggPrediction { diff --git a/image_analysis/bragg_prediction/BraggPredictionGPU.cu b/image_analysis/bragg_prediction/BraggPredictionGPU.cu index 8e172867..615eb794 100644 --- a/image_analysis/bragg_prediction/BraggPredictionGPU.cu +++ b/image_analysis/bragg_prediction/BraggPredictionGPU.cu @@ -127,13 +127,7 @@ namespace { out.d = 1.0f / sqrtf(recip_sq); out.dist_ewald = dist_ewald; out.rlp = 1.0f; - float partiality = 1.0f; - if (C.still_partiality && C.profile_radius_recipA > 0.0f) { - const float sig_bw = C.bandwidth_sigma * fabsf(recip_z); - const float sigma2 = C.profile_radius_recipA * C.profile_radius_recipA + sig_bw * sig_bw; - partiality = expf(-0.5f * dist_ewald * dist_ewald / sigma2); - } - out.partiality = partiality; + out.partiality = 1.0f; out.zeta = 1.0f; out.image_scale_corr = 1.0f; return true; @@ -164,9 +158,7 @@ namespace { float high_res_A, float ewald_dist_cutoff, char centering, - float bandwidth_sigma, - bool still_partiality, - float profile_radius_recipA) { + float bandwidth_sigma) { KernelConsts kc{}; auto geom = experiment.GetDiffractionGeometry(); kc.det_width_pxl = static_cast(experiment.GetXPixelsNum()); @@ -179,8 +171,6 @@ namespace { kc.one_over_wavelength = 1.0f / geom.GetWavelength_A(); kc.ewald_cutoff = ewald_dist_cutoff; kc.bandwidth_sigma = bandwidth_sigma; - kc.still_partiality = still_partiality; - kc.profile_radius_recipA = profile_radius_recipA; kc.Astar = lattice.Astar(); kc.Bstar = lattice.Bstar(); kc.Cstar = lattice.Cstar(); @@ -203,8 +193,7 @@ int BraggPredictionGPU::Calc(const DiffractionExperiment &experiment, const BraggPredictionSettings &settings) { // Build constants on host KernelConsts hK = BuildKernelConsts(experiment, lattice, settings.high_res_A, settings.ewald_dist_cutoff, - settings.centering, settings.bandwidth_sigma, - settings.still_partiality, settings.profile_radius_recipA); + settings.centering, settings.bandwidth_sigma); cudaMemcpyAsync(dK, &hK, sizeof(KernelConsts), cudaMemcpyHostToDevice, stream); cudaMemsetAsync(d_count, 0, sizeof(int), stream); diff --git a/image_analysis/bragg_prediction/BraggPredictionGPU.h b/image_analysis/bragg_prediction/BraggPredictionGPU.h index a7d2032d..ba0fc1f5 100644 --- a/image_analysis/bragg_prediction/BraggPredictionGPU.h +++ b/image_analysis/bragg_prediction/BraggPredictionGPU.h @@ -19,8 +19,6 @@ struct KernelConsts { float one_over_dmax_sq; float ewald_cutoff; float bandwidth_sigma; // relative Δλ/λ (sigma); 0 = monochromatic - bool still_partiality; // experimental stills excitation-error partiality (off => p = 1, fulls) - float profile_radius_recipA; // Gaussian sigma [1/A] for the stills partiality Coord Astar, Bstar, Cstar, S0; float rot[9]; char centering; diff --git a/image_analysis/indexing/AnalyzeIndexing.cpp b/image_analysis/indexing/AnalyzeIndexing.cpp index bc1661b8..af17244a 100644 --- a/image_analysis/indexing/AnalyzeIndexing.cpp +++ b/image_analysis/indexing/AnalyzeIndexing.cpp @@ -375,7 +375,10 @@ bool AnalyzeIndexing(DataMessage &message, int64_t indexing_lattice_count = 0; bool outcome = false; - const float min_frac = experiment.GetIndexingSettings().GetMinIndexedSpotFraction(); + // Minimum fraction of the in-resolution spots a candidate lattice must index to be accepted. + // Lowering it admits weaker/sparser crystals (more real ones on flooded XFEL frames, but also more + // spurious lattices that a downstream merge-consistency gate must remove). + constexpr float min_frac = 0.20f; if (nspots_indexed >= viable_cell_min_spots && nspots_indexed >= std::lround(min_frac * nspots_ref)) { auto uc = latt.GetUnitCell(); if (ok(uc.a) && ok(uc.b) && ok(uc.c) && ok(uc.alpha) && ok(uc.beta) && ok(uc.gamma)) { diff --git a/image_analysis/rotation_indexer/RotationIndexer.cpp b/image_analysis/rotation_indexer/RotationIndexer.cpp index 98bbecf8..20cf5603 100644 --- a/image_analysis/rotation_indexer/RotationIndexer.cpp +++ b/image_analysis/rotation_indexer/RotationIndexer.cpp @@ -253,9 +253,10 @@ void RotationIndexer::RunIndexing() { // Adopt the free triclinic cell only if it indexes CLEARLY more than the constrained cell - // a false promotion (a near-90 pseudo cell forced to ideal angles + a bogus centering) - // misplaces most reflections (CQ066 ratio ~0.1), whereas genuine higher symmetry (incl. - // R-centred) indexes comparably (ratio ~0.7). Preferring the constrained cell on a near-tie - // keeps the real symmetry/centering; the intensities settle the final space group. + // misplaces most reflections (measured indexed-fraction ratio ~0.1), whereas genuine higher + // symmetry (incl. R-centred) indexes comparably (ratio ~0.7). Preferring the constrained + // cell on a near-tie keeps the real symmetry/centering; the intensities settle the final + // space group. if (work[ci].has_tri) { Solved t = tri_f[ci].get(); if (t.ok && t.frac > 0.3f && frac < 0.5f * t.frac) { diff --git a/image_analysis/scale_merge/Merge.cpp b/image_analysis/scale_merge/Merge.cpp index fea7100b..52c31ed5 100644 --- a/image_analysis/scale_merge/Merge.cpp +++ b/image_analysis/scale_merge/Merge.cpp @@ -40,9 +40,7 @@ MergeOnTheFly::MergeOnTheFly(const DiffractionExperiment &x) high_resolution_limit(scaling_settings.GetHighResolutionLimit_A()), // A min-image-CC of 0 (the default) means "no limit": leave the optional // empty so the per-image CC cut is inactive. Otherwise a 0.0 threshold - // would silently drop every image with a non-positive per-image CC (which - // also wrongly zeroed N_obs in MergeStats, since it masks with cc_mask=true - // while the merge keeps all images). + // would silently drop every image with a non-positive per-image CC. image_cc_limit(scaling_settings.GetMinCCForImage() > 0.0 ? std::optional(scaling_settings.GetMinCCForImage()) : std::nullopt), @@ -64,10 +62,10 @@ bool MergeOnTheFly::IsMaskedRing(const Reflection &r) const { return ring >= 0 && ring < static_cast(masked_ice_rings.size()) && masked_ice_rings[ring]; } -void MergeOnTheFly::AddImage(const IntegrationOutcome &outcome, int64_t image_id, bool cc_mask) { +void MergeOnTheFly::AddImage(const IntegrationOutcome &outcome, int64_t image_id) { std::unique_lock ul(merged_mutex); - if (Mask(outcome, cc_mask)) + if (Mask(outcome)) return; const int half = HalfForImage(image_id); @@ -186,7 +184,7 @@ void MergeOnTheFly::RefineErrorModel(const std::vector &outc std::unordered_map> groups; for (const auto &outcome: outcomes) { - if (Mask(outcome, false)) + if (Mask(outcome)) continue; for (const auto &r: outcome.reflections) { if (generator.IsSystematicallyAbsent(r)) @@ -334,7 +332,7 @@ void MergeOnTheFly::RefineErrorModel(const std::vector &outc error_model_chi2 = chi2.empty() ? 0.0 : median(chi2) / CHI2_1_MEDIAN; } -bool MergeOnTheFly::Mask(const IntegrationOutcome &outcome, bool cc_mask) { +bool MergeOnTheFly::Mask(const IntegrationOutcome &outcome) { if (reference_cell) { auto cell = outcome.latt.GetUnitCell(); if (!cell.is_close(*reference_cell, @@ -343,7 +341,7 @@ bool MergeOnTheFly::Mask(const IntegrationOutcome &outcome, bool cc_mask) { return true; } - if (cc_mask && image_cc_limit) { + if (filter_by_image_cc && image_cc_limit) { if (!outcome.image_scale_cc || std::isnan(outcome.image_scale_cc.value()) || outcome.image_scale_cc.value() < image_cc_limit.value()) @@ -393,11 +391,10 @@ std::vector MergeOnTheFly::ExportReflections() { } std::vector MergeAll(const DiffractionExperiment &x, - const std::vector &integration_outcome, - bool mask) { + const std::vector &integration_outcome) { MergeOnTheFly merge(x); for (size_t i = 0; i < integration_outcome.size(); ++i) - merge.AddImage(integration_outcome[i], static_cast(i), mask); + merge.AddImage(integration_outcome[i], static_cast(i)); return merge.ExportReflections(); } @@ -413,6 +410,64 @@ struct ShellAccum { CorrelationCoefficient cc_ref; }; +std::pair ImageReferenceCC(const std::vector &reflections, + const std::map &reference, + const HKLKeyGenerator &generator, + std::optional d_min_limit, + double min_partiality) { + constexpr size_t MIN_REFLECTIONS = 20; + + double sum_x = 0.0; + double sum_y = 0.0; + double sum_x2 = 0.0; + double sum_y2 = 0.0; + double sum_xy = 0.0; + size_t n = 0; + + for (const auto &r: reflections) { + if (r.on_ice_ring) + continue; + if (!AcceptReflection(r, d_min_limit)) + continue; + if (r.partiality < min_partiality) + continue; + if (!std::isfinite(r.I) || !std::isfinite(r.image_scale_corr) || r.image_scale_corr <= 0.0f) + continue; + if (!std::isfinite(r.sigma) || r.sigma <= 0.0f) + continue; + + const auto it = reference.find(generator(r)); + if (it == reference.end()) + continue; + + const double image_i = static_cast(r.I) * static_cast(r.image_scale_corr); + const double ref_i = it->second; + + if (!std::isfinite(image_i) || !std::isfinite(ref_i)) + continue; + + sum_x += image_i; + sum_y += ref_i; + sum_x2 += image_i * image_i; + sum_y2 += ref_i * ref_i; + sum_xy += image_i * ref_i; + ++n; + } + + if (n < MIN_REFLECTIONS) + return {NAN, n}; + + const double nd = static_cast(n); + const double cov = sum_xy - sum_x * sum_y / nd; + const double var_x = sum_x2 - sum_x * sum_x / nd; + const double var_y = sum_y2 - sum_y * sum_y / nd; + + if (!(var_x > 0.0 && var_y > 0.0)) + return {NAN, n}; + + return {cov / std::sqrt(var_x * var_y), n}; +} + void CalcPossibleReflections(int space_group_number , const UnitCell &cell, double d_min, @@ -551,7 +606,7 @@ MergeStatistics MergeOnTheFly::MergeStats(const std::vector &m rmeas_obs.reserve(merged.size()); for (int i = 0; i < integration_outcome.size(); ++i) { - if (Mask(integration_outcome[i], true)) + if (Mask(integration_outcome[i])) continue; for (const auto &r: integration_outcome[i].reflections) { diff --git a/image_analysis/scale_merge/Merge.h b/image_analysis/scale_merge/Merge.h index cd19b7bd..a24509d4 100644 --- a/image_analysis/scale_merge/Merge.h +++ b/image_analysis/scale_merge/Merge.h @@ -100,6 +100,9 @@ class MergeOnTheFly { std::optional reference_cell; std::optional high_resolution_limit; std::optional image_cc_limit; + // Apply image_cc_limit in Mask(). One flag for the whole engine, not a per-call argument, so the + // merge, the error model and MergeStats can never disagree about which images are in. + bool filter_by_image_cc = false; double min_partiality = 0.02; // When set, ice-ring-flagged reflections are left out of this merge. Used for the P1 pass whose @@ -142,12 +145,13 @@ class MergeOnTheFly { std::unordered_map reject_median_I; size_t reject_count = 0; - bool Mask(const IntegrationOutcome &outcome, bool cc_mask); + bool Mask(const IntegrationOutcome &outcome); [[nodiscard]] bool IsMaskedRing(const Reflection &r) const; public: MergeOnTheFly(const DiffractionExperiment &x); MergeOnTheFly& ReferenceCell(const std::optional &cell); MergeOnTheFly& ExcludeIceRings(bool input) { exclude_ice_rings = input; return *this; } + MergeOnTheFly& FilterByImageCC(bool input) { filter_by_image_cc = input; return *this; } MergeOnTheFly& MaskIceRings(std::vector masked, float half_width_q) { masked_ice_rings = std::move(masked); mask_ice_half_width_q = half_width_q; return *this; } @@ -166,7 +170,7 @@ public: // image_id is the image's stable identity (its index in the outcomes vector). The CC1/2 half-set // is a deterministic hash of it, so the split is reproducible run-to-run and independent of the // order (or threading) of AddImage calls - not a draw from a shared RNG in call order. - void AddImage(const IntegrationOutcome& outcome, int64_t image_id, bool cc_mask = false); + void AddImage(const IntegrationOutcome& outcome, int64_t image_id); // d_min_override, when set, is the effective high-resolution limit for the shell table (used for // the automatic resolution cutoff computed by the caller); otherwise the manual @@ -180,5 +184,16 @@ public: }; std::vector MergeAll(const DiffractionExperiment &x, - const std::vector &reflections, - bool mask = false); + const std::vector &reflections); + +// Pearson CC between one image's corrected intensities (I * image_scale_corr) and a reference set of +// full intensities, over the reflections that would enter the merge (non-ice, within the resolution +// limit, partiality above the floor, finite). {NAN, n} when fewer than 20 reflections qualify. +// This is the per-image image_scale_cc: ScaleOnTheFly sets it, and StillsPartialityRefine recomputes it +// after refining the partiality model, so the reported CC always describes the corrections that will be +// merged - which matters because --min-image-cc drops images by it. +std::pair ImageReferenceCC(const std::vector &reflections, + const std::map &reference, + const HKLKeyGenerator &generator, + std::optional d_min_limit, + double min_partiality); diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index 324a6c40..290bef99 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -169,7 +169,7 @@ RotationScaleMerge::RotationScaleMerge(const DiffractionExperiment &experiment, rfree_fraction = s.GetRfreeFraction(); scale_fulls = s.GetScaleFulls(); // Decay + absorption correction surfaces are one master toggle (on by default; both cross-validated, - // so a no-op when their systematic is absent). Decoupled from the stills-only -B / RefineB flag. + // so a no-op when their systematic is absent). refine_decay_b = s.GetCorrectionSurfaces(); absorption_iter = s.GetCorrectionSurfaces() ? s.GetAbsorptionIter() : 0; modulation_iter = s.GetCorrectionSurfaces() ? s.GetAbsorptionIter() : 0; @@ -1293,7 +1293,6 @@ void RotationScaleMerge::FinalizePerFrameScale(const std::vector &cc, co o.image_scale_cc_n.reset(); o.mosaicity_deg.reset(); } - o.image_scale_b_factor_Ang2.reset(); o.image_scale_wedge_deg.reset(); } } diff --git a/image_analysis/scale_merge/ScaleOnTheFly.cpp b/image_analysis/scale_merge/ScaleOnTheFly.cpp index 175b2aac..2c941c65 100644 --- a/image_analysis/scale_merge/ScaleOnTheFly.cpp +++ b/image_analysis/scale_merge/ScaleOnTheFly.cpp @@ -8,7 +8,6 @@ #include #include #include -#include namespace { // Robust loss scale (in sigma units) for the per-image scale fit: a few outlier reflections @@ -70,30 +69,6 @@ namespace { // The fixed-partiality residual for the Ceres path (used only when the B-factor is refined): the // stored partiality is a constant, so the model is G * partiality * exp(-B/(4 d^2)) * (1/rlp) * Itrue. - struct IntensityFixedResidual { - IntensityFixedResidual(const Reflection &r, double Itrue, double sigma) - : Iobs(r.I), - Itrue(Itrue), - weight(SafeInv(sigma, 1.0)), - lp(SafeInv(r.rlp, 1.0)), - b_resolution_coeff(-SafeInv(4.0 * r.d * r.d, 0.0)), - partiality(r.partiality) { - } - - template - bool operator()(const T *const G, const T *const B, T *residual) const { - const T B_term = ceres::exp(B[0] * T(b_resolution_coeff)); - residual[0] = (G[0] * T(partiality) * B_term * T(lp) * Itrue - T(Iobs)) * T(weight); - return true; - } - - const double Iobs; - const double Itrue; - const double weight; - const double lp; - const double b_resolution_coeff; - const double partiality; - }; } ScaleOnTheFly::ScaleOnTheFly(const DiffractionExperiment &x, const std::vector &ref) @@ -112,80 +87,23 @@ bool ScaleOnTheFly::Accept(const Reflection &r) const { return AcceptReflection(r, s.GetHighResolutionLimit_A()); } -std::pair ScaleOnTheFly::CalculateGlobalCC(const std::vector &reflections) const { - double sum_x = 0.0; - double sum_y = 0.0; - double sum_x2 = 0.0; - double sum_y2 = 0.0; - double sum_xy = 0.0; - size_t n = 0; - - for (const auto &r: reflections) { - if (r.on_ice_ring) - continue; - if (!AcceptReflection(r, s.GetHighResolutionLimit_A())) - continue; - if (r.partiality < s.GetMinPartiality()) - continue; - if (!std::isfinite(r.I) || !std::isfinite(r.image_scale_corr) || r.image_scale_corr <= 0.0f) - continue; - if (!std::isfinite(r.sigma) || r.sigma <= 0.0f) - continue; - - const HKLKey key = hkl_key_generator(r); - const auto it = reference_data.find(key); - if (it == reference_data.end()) - continue; - - const double image_i = static_cast(r.I) * static_cast(r.image_scale_corr); - const double ref_i = it->second; - - if (!std::isfinite(image_i) || !std::isfinite(ref_i)) - continue; - - sum_x += image_i; - sum_y += ref_i; - sum_x2 += image_i * image_i; - sum_y2 += ref_i * ref_i; - sum_xy += image_i * ref_i; - ++n; - } - - if (n < MIN_REFLECTIONS) - return {NAN, n}; - - const double nd = static_cast(n); - const double cov = sum_xy - sum_x * sum_y / nd; - const double var_x = sum_x2 - sum_x * sum_x / nd; - const double var_y = sum_y2 - sum_y * sum_y / nd; - - if (!(var_x > 0.0 && var_y > 0.0)) - return {NAN, n}; - - return {cov / std::sqrt(var_x * var_y), n}; -} - void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const { if (integration_outcome.reflections.empty()) return; auto start = std::chrono::steady_clock::now(); - ScaleOnTheFlyResult result{ .B = 0.0, .G = 1.0 }; + ScaleOnTheFlyResult result{ .G = 1.0 }; auto clear_scale = [&]() { integration_outcome.image_scale_cc.reset(); integration_outcome.image_scale_cc_n.reset(); integration_outcome.image_scale_g.reset(); - integration_outcome.image_scale_b_factor_Ang2.reset(); }; - // With B fixed the fixed-partiality model G * coeff is linear in G, so the robust per-image scale is a - // 1-D M-estimate solved directly (IRLS) instead of a Ceres problem per image. Ceres is kept only when - // the B-factor (exp(-B/...)) is refined. - const bool linear_in_g = !s.GetRefineB(); - - if (linear_in_g) { + // The fixed-partiality model G * coeff is linear in G, so the robust per-image scale is a 1-D + // M-estimate solved directly (IRLS) rather than a Ceres problem per image. + { std::vector obs; obs.reserve(integration_outcome.reflections.size()); for (const auto &r: integration_outcome.reflections) { @@ -194,8 +112,7 @@ void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const { const auto it = reference_data.find(hkl_key_generator(r)); if (it == reference_data.end()) continue; - const double B_term = std::exp(result.B * -SafeInv(4.0 * r.d * r.d, 0.0)); - const double coeff = r.partiality * B_term * SafeInv(r.rlp, 1.0) * it->second; + const double coeff = r.partiality * SafeInv(r.rlp, 1.0) * it->second; obs.push_back({coeff, static_cast(r.I), SafeInv(r.sigma, 1.0)}); } @@ -205,52 +122,18 @@ void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const { } result.G = SolveScaleIRLS(obs, SCALE_ROBUST_K); - } else { - ceres::Problem problem; - - size_t n_reflections = 0; - for (const auto &r: integration_outcome.reflections) { - if (!Accept(r)) - continue; - - const HKLKey key = hkl_key_generator(r); - if (!reference_data.contains(key)) - continue; - - ++n_reflections; - - auto *cost = new ceres::AutoDiffCostFunction( - new IntensityFixedResidual(r, reference_data.at(key), r.sigma)); - problem.AddResidualBlock(cost, new ceres::CauchyLoss(SCALE_ROBUST_K), &result.G, &result.B); - } - - if (n_reflections < MIN_REFLECTIONS) { - clear_scale(); - return; - } - - problem.SetParameterLowerBound(&result.G, 0, 0.0); - problem.SetParameterLowerBound(&result.B, 0, s.GetMinB()); - problem.SetParameterUpperBound(&result.B, 0, s.GetMaxB()); - - ceres::Solver::Options options; - options.linear_solver_type = ceres::DENSE_QR; - options.minimizer_progress_to_stdout = false; - options.num_threads = 1; - - ceres::Solver::Summary summary; - ceres::Solve(options, &problem, &summary); } for (auto &r: integration_outcome.reflections) { - const double B_term = exp(result.B * -SafeInv(4.0 * r.d * r.d, 0.0)); - const double denom = B_term * r.partiality * result.G; + const double denom = r.partiality * result.G; r.image_scale_corr = (std::isfinite(r.rlp) && std::isfinite(denom) && denom > 0.0) ? static_cast(r.rlp / denom) : NAN; } - const auto [cc, cc_n] = CalculateGlobalCC(integration_outcome.reflections); + const auto [cc, cc_n] = ImageReferenceCC(integration_outcome.reflections, reference_data, + hkl_key_generator, s.GetHighResolutionLimit_A(), + s.GetMinPartiality()); result.cc = cc; result.cc_n = cc_n; @@ -261,11 +144,6 @@ void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const { integration_outcome.image_scale_cc_n = cc_n; integration_outcome.image_scale_g = result.G; integration_outcome.image_scale_wedge_deg.reset(); - - if (s.GetRefineB()) - integration_outcome.image_scale_b_factor_Ang2 = result.B; - else - integration_outcome.image_scale_b_factor_Ang2.reset(); } void ScaleOnTheFly::Scale(std::vector &integration, size_t nthreads) const { diff --git a/image_analysis/scale_merge/ScaleOnTheFly.h b/image_analysis/scale_merge/ScaleOnTheFly.h index 67bd5623..bc8ae639 100644 --- a/image_analysis/scale_merge/ScaleOnTheFly.h +++ b/image_analysis/scale_merge/ScaleOnTheFly.h @@ -13,7 +13,6 @@ struct ScaleOnTheFlyResult { - double B = 0; double G = 1.0; double cc = NAN; size_t cc_n = 0; @@ -23,9 +22,8 @@ struct ScaleOnTheFlyResult { // Per-image reference scaling with the FIXED partiality model: each reflection's stored partiality is // used as-is (it is 1 for stills and the zeta/erf rocking-curve value already set at prediction for -// rotation data). No partiality recompute, no mosaicity or wedge refinement. The B-factor may be refined -// (Ceres); otherwise the fit is linear in G (a robust 1-D IRLS). Rotation self-scaling/merging offline -// uses the dedicated RotationScaleMerge path instead. +// rotation data). No partiality recompute, no mosaicity or wedge refinement: the fit is linear in G, a +// robust 1-D IRLS. Rotation self-scaling/merging offline uses the dedicated RotationScaleMerge path. class ScaleOnTheFly { constexpr static size_t MIN_REFLECTIONS = 20; @@ -34,7 +32,6 @@ class ScaleOnTheFly { std::map reference_data; bool Accept(const Reflection &r) const; - [[nodiscard]] std::pair CalculateGlobalCC(const std::vector &reflections) const; public: ScaleOnTheFly(const DiffractionExperiment &x, const std::vector &ref); diff --git a/image_analysis/scale_merge/ScalingResult.cpp b/image_analysis/scale_merge/ScalingResult.cpp index 83874371..1a02d09e 100644 --- a/image_analysis/scale_merge/ScalingResult.cpp +++ b/image_analysis/scale_merge/ScalingResult.cpp @@ -10,7 +10,6 @@ ScalingResult::ScalingResult(size_t n) : image_scale_g(n, NAN), mosaicity_deg(n, NAN), - image_bfactor_Ang2(n, NAN), rotation_wedge_deg(n, NAN), image_cc(n, NAN), image_cc_n(n, 0) {} @@ -18,14 +17,12 @@ ScalingResult::ScalingResult(size_t n) ScalingResult::ScalingResult(const std::vector &v) : image_scale_g(v.size(), NAN), mosaicity_deg(v.size(), NAN), - image_bfactor_Ang2(v.size(), NAN), rotation_wedge_deg(v.size(), NAN), image_cc(v.size(), NAN), image_cc_n(v.size(), 0) { for (int i = 0; i < v.size(); i++) { image_scale_g[i] = v[i].image_scale_g.value_or(NAN); mosaicity_deg[i] = v[i].mosaicity_deg.value_or(NAN); - image_bfactor_Ang2[i] = v[i].image_scale_b_factor_Ang2.value_or(NAN); rotation_wedge_deg[i] = v[i].image_scale_wedge_deg.value_or(NAN); image_cc[i] = v[i].image_scale_cc.value_or(NAN); image_cc_n[i] = v[i].image_scale_cc_n.value_or(0); @@ -41,12 +38,11 @@ void ScalingResult::SaveToFile(const std::string &filename) { } // Header so the columns are self-describing (lines starting with '#' are comments for gnuplot/numpy). - img_file << "# image_number scale_G bfactor_Ang2 mosaicity_deg wedge_deg cc_to_merge cc_n\n"; + img_file << "# image_number scale_G mosaicity_deg wedge_deg cc_to_merge cc_n\n"; for (size_t i = 0; i < image_scale_g.size(); ++i) { img_file << i << " " << image_scale_g[i] - << " " << image_bfactor_Ang2[i] << " " << mosaicity_deg[i] << " " << rotation_wedge_deg[i] << " " << image_cc[i] diff --git a/image_analysis/scale_merge/ScalingResult.h b/image_analysis/scale_merge/ScalingResult.h index 7003b5a8..717d1915 100644 --- a/image_analysis/scale_merge/ScalingResult.h +++ b/image_analysis/scale_merge/ScalingResult.h @@ -10,7 +10,6 @@ struct ScalingResult { std::vector image_scale_g; std::vector mosaicity_deg; - std::vector image_bfactor_Ang2; std::vector rotation_wedge_deg; std::vector image_cc; std::vector image_cc_n; diff --git a/image_analysis/scale_merge/StillsPartialityRefine.cpp b/image_analysis/scale_merge/StillsPartialityRefine.cpp index 03f60189..9387fdff 100644 --- a/image_analysis/scale_merge/StillsPartialityRefine.cpp +++ b/image_analysis/scale_merge/StillsPartialityRefine.cpp @@ -39,7 +39,11 @@ namespace { // Analytic partiality for a reflection whose base reciprocal vector is q, tilted by (psi_x, psi_y). // Mirrors BraggPrediction: dist_ewald = |S| - 1/lambda with S = q_rot + S0, and - // p = exp(-dist_ewald^2 / 2 sigma^2), sigma^2 = (gamma0 + gamma_e*d*)^2 + (bw*|q_z|)^2. + // p = exp(-dist_ewald^2 / 2 sigma^2), sigma^2 = gamma0^2 + (gamma_e*d*)^2 + (bw*|q_z|)^2 - three + // independent broadenings added in quadrature: the reciprocal-lattice point's own radius (gamma0, + // ~1/domain size, resolution-INdependent), the mosaic/divergence spread (gamma_e*d*, proportional to + // d*) and the bandwidth smear along the beam. In practice the fit below returns gamma_e ~ 0 and the + // width is essentially gamma0 - see there. double ComputeP(double qx, double qy, double qz, double psi_x, double psi_y, double s0x, double s0y, double s0z, double inv_lambda, @@ -51,9 +55,9 @@ namespace { const double Sx = qr[0] + s0x, Sy = qr[1] + s0y, Sz = qr[2] + s0z; const double de = std::sqrt(Sx * Sx + Sy * Sy + Sz * Sz) - inv_lambda; const double dstar = std::sqrt(qr[0] * qr[0] + qr[1] * qr[1] + qr[2] * qr[2]); - const double sig = gamma0 + gamma_e * dstar; + const double sig_ang = gamma_e * dstar; const double sbw = bw * std::fabs(qr[2]); - const double sig2 = sig * sig + sbw * sbw; + const double sig2 = gamma0 * gamma0 + sig_ang * sig_ang + sbw * sbw; if (!(sig2 > 0.0)) return 1.0; return std::exp(-0.5 * de * de / sig2); @@ -113,9 +117,9 @@ namespace { const T Sx = qr[0] + T(s0x), Sy = qr[1] + T(s0y), Sz = qr[2] + T(s0z); const T de = ceres::sqrt(Sx * Sx + Sy * Sy + Sz * Sz) - T(inv_lambda); const T dstar = ceres::sqrt(qr[0] * qr[0] + qr[1] * qr[1] + qr[2] * qr[2]); - const T sig = T(gamma0) + T(gamma_e) * dstar; + const T sig_ang = T(gamma_e) * dstar; const T sbw = T(bw) * ceres::abs(qr[2]); - const T sig2 = sig * sig + sbw * sbw; + const T sig2 = T(gamma0) * T(gamma0) + sig_ang * sig_ang + sbw * sbw; const T p = ceres::exp(T(-0.5) * de * de / sig2); residual[0] = T(weight) * (T(G) * p * T(lp) * T(Iref) - T(Iobs)); return true; @@ -136,13 +140,10 @@ namespace { } StillsPartialityRefine::StillsPartialityRefine(const DiffractionExperiment &x) - : StillsPartialityRefine(x, Settings{}) {} - -StillsPartialityRefine::StillsPartialityRefine(const DiffractionExperiment &x, Settings settings) : experiment_(x), - settings_(settings), hkl_key_generator_(x.GetScalingSettings().GetMergeFriedel(), x.GetSpaceGroupNumber().value_or(1)), d_min_limit_(x.GetScalingSettings().GetHighResolutionLimit_A()), + min_partiality_(x.GetScalingSettings().GetMinPartiality()), bandwidth_sigma_(x.GetBandwidthFWHM().value_or(0.0f) / 2.3548f) {} double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, @@ -156,7 +157,6 @@ double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, const Coord S0 = outcome.geom.GetScatteringVector(); const double inv_lambda = 1.0 / outcome.geom.GetWavelength_A(); const double bw = bandwidth_sigma_; - const double gamma0 = 0.0; // width is purely angular: sigma(d*) = gamma_e * d* (set per crystal below) auto base_q = [&](const Reflection &r) { return Astar * static_cast(r.h) + Bstar * static_cast(r.k) @@ -166,7 +166,9 @@ double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, // Collect the reflections that constrain the fit (accepted, non-ice, finite, present in the reference). std::vector obs; obs.reserve(outcome.reflections.size()); - double sum_ang2 = 0.0; // RMS angular excitation error (dist_ewald / d*) -> per-crystal mosaic width + // Moments of the excitation error against resolution: de^2 ~ gamma0^2 + gamma_e^2 * d*^2, fitted per + // crystal by ordinary least squares on (d*^2, de^2). Both components come out of the data. + double m_n = 0.0, m_x = 0.0, m_xx = 0.0, m_y = 0.0, m_xy = 0.0; size_t n_de = 0; for (const Reflection &r: outcome.reflections) { if (r.on_ice_ring || !AcceptReflection(r, d_min_limit_)) @@ -186,15 +188,20 @@ double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, .weight = SafeInv(r.sigma, 1.0), }); - // Angular excitation error delta_psi = dist_ewald / d* at the stored orientation (psi = 0). Using - // the ANGULAR distance (not the linear reciprocal-space distance) makes the partiality width - // resolution-clean: a fixed mosaic angle smears high-resolution rlps more in reciprocal space, so a - // constant linear width computes p too small at high resolution and over-divides those shells. + // Excitation error at the stored orientation (psi = 0), collected as the moments of de^2 against + // d*^2, so BOTH width components are fitted rather than one being forced to zero. Forcing the + // width to be purely angular (gamma0 = 0) pins it to the high-resolution edge - it is fitted over + // a d*^2-dense population - and it then collapses at low d*, giving p ~ 0 for reflections that + // were plainly recorded, which inflated the merged low-resolution intensity scale ~3.6x. const double dstar = VecLen(q.x, q.y, q.z); const double de0 = VecLen(q.x + S0.x, q.y + S0.y, q.z + S0.z) - inv_lambda; if (dstar > 1e-9) { - const double dpsi = de0 / dstar; - sum_ang2 += dpsi * dpsi; + const double x = dstar * dstar, y = de0 * de0; + m_n += 1.0; + m_x += x; + m_xx += x * x; + m_y += y; + m_xy += x * y; ++n_de; } } @@ -202,12 +209,28 @@ double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, if (obs.size() < MIN_FIT_REFLECTIONS || n_de == 0) return 0.0; - // Per-crystal angular mosaic width from the RMS angular excitation error. sigma(d*) = gamma_e * d* - // (gamma0 = 0), i.e. p = exp(-0.5 (delta_psi / gamma_e)^2) is a Gaussian in the angular distance from - // the Ewald sphere - the physical mosaic/divergence model, independent of resolution. A positive - // settings_.gamma_e overrides the per-crystal estimate with a shared (pooled) width. - const double gamma_e_ang = std::max(std::sqrt(sum_ang2 / static_cast(n_de)), 1e-9); - const double gamma_e = settings_.gamma_e > 0.0 ? settings_.gamma_e : gamma_e_ang; + // Solve the 2x2 normal equations for de^2 = A + B d*^2. A degenerate spread in d* (all reflections in + // one shell) leaves B undetermined, so fall back to the pure angular width there; a negative fitted + // component is unphysical and is clamped to zero, which reduces to the previous model. + // + // Measured outcome, worth knowing before touching this: the fit does NOT split the width between the + // two terms - it returns gamma0 ~ 4e-4 1/A and gamma_e ~ 0 (their cross-over sits at d = 0.3 A, far + // outside any measured range), i.e. a width constant in the LINEAR Ewald distance. That is + // structural, not a fluke: prediction accepts reflections on a fixed linear |dist_ewald| cutoff, so + // the accepted population's de^2 is flat in d*^2 by construction and the slope is genuinely ~0. The + // truncated population cannot constrain an angular term; the resolution-independent one is what the + // data actually support. + const double det = m_n * m_xx - m_x * m_x; + double A = 0.0, B = 0.0; + if (std::fabs(det) > 1e-30) { + A = (m_xx * m_y - m_x * m_xy) / det; + B = (m_n * m_xy - m_x * m_y) / det; + } else { + B = m_x > 0.0 ? m_y / m_x : 0.0; + } + const double gamma0 = std::sqrt(std::max(0.0, A)); + const double gamma_e_fit = std::max(std::sqrt(std::max(0.0, B)), 1e-9); + const double gamma_e = settings_.gamma_e > 0.0 ? settings_.gamma_e : gamma_e_fit; double psi[2] = {0.0, 0.0}; double G = 1.0; @@ -262,19 +285,28 @@ double StillsPartialityRefine::RefineOne(IntegrationOutcome &outcome, } // Write the refined partiality + scale correction onto every reflection of the crystal (not only the - // fit subset), so the merge sees a consistent model. image_scale_corr = rlp / (partiality * G). + // fit subset), so the merge sees a consistent model. image_scale_corr = rlp / (partiality * G), the + // same composition ScaleOnTheFly writes. for (auto &r: outcome.reflections) { const Coord q = base_q(r); const double p = ComputeP(q.x, q.y, q.z, psi[0], psi[1], S0.x, S0.y, S0.z, inv_lambda, gamma0, gamma_e, bw); r.partiality = static_cast(p); const double denom = p * G; - r.image_scale_corr = (std::isfinite(r.rlp) && denom > 0.0) + r.image_scale_corr = (std::isfinite(r.rlp) && std::isfinite(denom) && denom > 0.0) ? static_cast(r.rlp / denom) : NAN; } outcome.image_scale_g = static_cast(G); + // The corrections just changed, so the CC that ScaleOnTheFly measured no longer describes them. + // Refresh it here: it is reported per image and --min-image-cc drops images by it, so it has to be + // the CC of the data that is actually merged. + const auto [cc, cc_n] = ImageReferenceCC(outcome.reflections, reference, hkl_key_generator_, + d_min_limit_, min_partiality_); + outcome.image_scale_cc = cc; + outcome.image_scale_cc_n = cc_n; + const double tilt_deg = std::sqrt(psi[0] * psi[0] + psi[1] * psi[1]) * kRadToDeg; return tilt_deg; } @@ -288,7 +320,7 @@ double StillsPartialityRefine::Run(std::vector &outcomes, si for (int outer = 0; outer < settings_.outer_iterations; ++outer) { // Reference full intensities from the current corrections. - const std::vector merged = MergeAll(experiment_, outcomes, false); + const std::vector merged = MergeAll(experiment_, outcomes); std::map reference; for (const auto &m: merged) reference[hkl_key_generator_(m)] = m.I; diff --git a/image_analysis/scale_merge/StillsPartialityRefine.h b/image_analysis/scale_merge/StillsPartialityRefine.h index 108f7cac..4db99990 100644 --- a/image_analysis/scale_merge/StillsPartialityRefine.h +++ b/image_analysis/scale_merge/StillsPartialityRefine.h @@ -9,7 +9,7 @@ #include "../IntegrationOutcome.h" #include "HKLKey.h" -// Experimental physical partiality post-refinement for STILLS (env JFJOCH_STILL_POSTREFINE). +// Physical partiality post-refinement for STILLS (on by default; rugnux --simple-stills opts out). // // The default stills partiality is a frozen scalar-sigma Gaussian (or p == 1): p is set once at // prediction and never optimised, and any attempt to free a per-image sigma jointly with the per-image @@ -41,16 +41,16 @@ public: }; explicit StillsPartialityRefine(const DiffractionExperiment &x); - StillsPartialityRefine(const DiffractionExperiment &x, Settings settings); // Refine all crystals in place. Returns the mean |dpsi| applied (degrees), for diagnostics. double Run(std::vector &outcomes, size_t nthreads = 0) const; private: const DiffractionExperiment experiment_; - const Settings settings_; + const Settings settings_{}; const HKLKeyGenerator hkl_key_generator_; const std::optional d_min_limit_; + const double min_partiality_; const float bandwidth_sigma_; // Refine one crystal against the reference map; returns |dpsi| in degrees (0 if skipped). diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp index a722a67c..8ccdcf06 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include "AdaptiveSpotFinderCPU.h" #include "AdaptiveThreshold.h" @@ -59,9 +58,8 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image } } -std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) { +void AdaptiveSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings) { const auto &pixel_to_bin = mapping.GetPixelToBin(); const size_t nbins = ring_sum.size(); const size_t npix = static_cast(width) * height; @@ -79,8 +77,11 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB g_sum += ring_sum[b]; g_sum2 += ring_sum2[b]; } - if (n_total == 0) - return {}; + if (n_total == 0) { + // Nothing valid to threshold against: leave no strong pixels for ExtractSpots to build on. + std::fill(output_buffer.begin(), output_buffer.end(), 0); + return; + } const double E = std::max(1.0f, settings.false_pixels_per_frame); double p = E / static_cast(n_total); @@ -122,7 +123,4 @@ std::vector AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB } if (npix % 32 != 0) output_buffer[OutputSize() - 1] = out.to_ulong(); - - // --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) --- - return ExtractSpots(image, settings, res_mask); } diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h index d75e937b..bb4aa220 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderCPU.h @@ -39,7 +39,5 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinder { public: explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping); - std::vector Run(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) override; + void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) override; }; diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu index 4403bde3..3a0b4d5d 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu +++ b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.cu @@ -257,12 +257,11 @@ void AdaptiveSpotFinderGPU::ComputeThresholds(const SpotFindingSettings &setting } } -std::vector AdaptiveSpotFinderGPU::Run(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) { +void AdaptiveSpotFinderGPU::Detect(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings) { if (image.size() != npix) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "AdaptiveSpotFinderGPU::Run: mismatch in pixel size"); + "AdaptiveSpotFinderGPU::Detect: mismatch in pixel size"); // --- Stage A: robust per-ring background (one plain pass + two sigma-clip passes) --- cuda_err(cudaMemsetAsync(gpu_sum, 0, sizeof(float) * nbins, *stream)); @@ -303,8 +302,11 @@ std::vector AdaptiveSpotFinderGPU::Run(const ImagePreprocessorB last_profile.Clear(mapping); last_profile.Add(prof_sum, prof_sum2, prof_count); - if (host_thr.empty()) - return {}; + if (host_thr.empty()) { + // Nothing valid to threshold against: leave no strong pixels for ExtractSpots to build on. + std::fill(output_buffer.begin(), output_buffer.end(), 0); + return; + } // --- Stage C: flag strong pixels into the bit buffer (value >= ring threshold) --- cuda_err(cudaMemcpyAsync(gpu_thr, host_thr.data(), sizeof(float) * nbins, cudaMemcpyHostToDevice, *stream)); @@ -313,7 +315,4 @@ std::vector AdaptiveSpotFinderGPU::Run(const ImagePreprocessorB image.getGPUBuffer(), gpu_pixel_to_bin, gpu_thr, gpu_strong, npix, nbins); cuda_err(cudaMemcpyAsync(output_buffer.data(), gpu_strong, OutputByteSize(), cudaMemcpyDeviceToHost, *stream)); cuda_err(cudaStreamSynchronize(*stream)); - - // --- Stage D: connected components + resolution mask + min/max-pix (shared host path) --- - return ExtractSpots(image, settings, res_mask); } diff --git a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h index e115ca8f..78740f2a 100644 --- a/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h +++ b/image_analysis/spot_finding/AdaptiveSpotFinderGPU.h @@ -93,11 +93,9 @@ public: AdaptiveSpotFinderGPU(const AdaptiveSpotFinderGPU &) = delete; AdaptiveSpotFinderGPU &operator=(const AdaptiveSpotFinderGPU &) = delete; - std::vector Run(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) override; + void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) override; - // The azimuthal profile computed as a byproduct of the last Run() - lets this engine replace the + // The azimuthal profile computed as a byproduct of the last Detect() - lets this engine replace the // separate azint pass in the analysis pipeline. [[nodiscard]] const AzimuthalIntegrationProfile &GetProfile() const { return last_profile; } }; diff --git a/image_analysis/spot_finding/DetModuleSpotFinder_cpu.h b/image_analysis/spot_finding/DetModuleSpotFinder_cpu.h index 5506387c..e0edbf2a 100644 --- a/image_analysis/spot_finding/DetModuleSpotFinder_cpu.h +++ b/image_analysis/spot_finding/DetModuleSpotFinder_cpu.h @@ -39,7 +39,7 @@ void FindSpots(DeviceOutput &output, || (col == 767) || (col == 768)) bad_pixel = 1; - if ((d_array[coord] < settings.high_resolution_limit) + if ((d_array[coord] < settings.high_resolution_limit.value_or(0.0f)) || (d_array[coord] > settings.low_resolution_limit)) bad_pixel = 1; diff --git a/image_analysis/spot_finding/ImageSpotFinder.cpp b/image_analysis/spot_finding/ImageSpotFinder.cpp index 895b8dba..9d0e2939 100644 --- a/image_analysis/spot_finding/ImageSpotFinder.cpp +++ b/image_analysis/spot_finding/ImageSpotFinder.cpp @@ -20,6 +20,13 @@ size_t ImageSpotFinder::OutputByteSize() const { return OutputSize() * sizeof(uint32_t); } +std::vector ImageSpotFinder::Run(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings, + const std::vector &res_mask) { + Detect(image, settings); + return ExtractSpots(image, settings, res_mask); +} + std::vector ImageSpotFinder::ExtractSpots(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) { diff --git a/image_analysis/spot_finding/ImageSpotFinder.h b/image_analysis/spot_finding/ImageSpotFinder.h index 11691aee..b4b938a3 100644 --- a/image_analysis/spot_finding/ImageSpotFinder.h +++ b/image_analysis/spot_finding/ImageSpotFinder.h @@ -18,13 +18,19 @@ protected: ImageSpotFinder(int32_t width, int32_t height); size_t OutputSize() const; size_t OutputByteSize() const; - std::vector ExtractSpots(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask); public: constexpr static int32_t MIN_VALID_PIXELS = 100; constexpr static int NBX = 15; virtual ~ImageSpotFinder() = default; - virtual std::vector Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) = 0; + + // Detect flags the image's strong pixels into the internal bit buffer - the expensive step (local + // box or per-ring background over every pixel). ExtractSpots then builds the spots from those + // pixels; min/max-pix and the resolution mask enter only there, so several min-pix values can be + // tried on ONE detection pass (MXAnalysisWithoutFPGA does that when min-pix is chosen per image). + virtual void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) = 0; + std::vector ExtractSpots(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask); + std::vector Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask); }; diff --git a/image_analysis/spot_finding/ImageSpotFinderCPU.cpp b/image_analysis/spot_finding/ImageSpotFinderCPU.cpp index 97188897..9b203baf 100644 --- a/image_analysis/spot_finding/ImageSpotFinderCPU.cpp +++ b/image_analysis/spot_finding/ImageSpotFinderCPU.cpp @@ -9,9 +9,8 @@ ImageSpotFinderCPU::ImageSpotFinderCPU(int32_t in_width, int32_t in_height) : ImageSpotFinder(in_width, in_height) {} -std::vector ImageSpotFinderCPU::Run(const ImagePreprocessorBuffer &image, - const SpotFindingSettings &settings, - const std::vector &res_mask) { +void ImageSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image, + const SpotFindingSettings &settings) { for (int i = 0; i < OutputSize(); i++) output_buffer[i] = 0; @@ -131,6 +130,4 @@ std::vector ImageSpotFinderCPU::Run(const ImagePreprocessorBuff if (height * width % 32 != 0) output_buffer[OutputSize() - 1] = out.to_ulong(); - - return ExtractSpots(image, settings, res_mask); } diff --git a/image_analysis/spot_finding/ImageSpotFinderCPU.h b/image_analysis/spot_finding/ImageSpotFinderCPU.h index 29ae00f4..2f8c28c3 100644 --- a/image_analysis/spot_finding/ImageSpotFinderCPU.h +++ b/image_analysis/spot_finding/ImageSpotFinderCPU.h @@ -20,7 +20,7 @@ class ImageSpotFinderCPU : public ImageSpotFinder { public: ImageSpotFinderCPU(int32_t width, int32_t height); - std::vector Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask); + void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) override; }; diff --git a/image_analysis/spot_finding/ImageSpotFinderGPU.cu b/image_analysis/spot_finding/ImageSpotFinderGPU.cu index f1afc258..2a1afc76 100644 --- a/image_analysis/spot_finding/ImageSpotFinderGPU.cu +++ b/image_analysis/spot_finding/ImageSpotFinderGPU.cu @@ -237,7 +237,7 @@ ImageSpotFinderGPU::ImageSpotFinderGPU(int32_t in_width, int32_t in_height, gpu_out_1 = CudaDevicePtr(OutputSize()); } -std::vector ImageSpotFinderGPU::Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) { +void ImageSpotFinderGPU::Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) { spot_parameters spot_params{}; spot_params.height = height; spot_params.width = width; @@ -273,6 +273,4 @@ std::vector ImageSpotFinderGPU::Run(const ImagePreprocessorBuff cuda_err(cudaMemcpyAsync(output_buffer.data(), gpu_out_1, OutputSize() * sizeof(uint32_t), cudaMemcpyDeviceToHost, *stream)); cuda_err(cudaStreamSynchronize(*stream)); - - return ExtractSpots(image, settings, res_mask); } diff --git a/image_analysis/spot_finding/ImageSpotFinderGPU.h b/image_analysis/spot_finding/ImageSpotFinderGPU.h index c18afbe2..0dab3a74 100644 --- a/image_analysis/spot_finding/ImageSpotFinderGPU.h +++ b/image_analysis/spot_finding/ImageSpotFinderGPU.h @@ -23,7 +23,7 @@ public: ImageSpotFinderGPU(int32_t width, int32_t height, std::shared_ptr stream); ~ImageSpotFinderGPU() override = default; - std::vector Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings, const std::vector &res_mask) override; + void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) override; }; diff --git a/image_analysis/spot_finding/SpotFindingSettings.h b/image_analysis/spot_finding/SpotFindingSettings.h index 96a4c9af..76cff2b0 100644 --- a/image_analysis/spot_finding/SpotFindingSettings.h +++ b/image_analysis/spot_finding/SpotFindingSettings.h @@ -16,7 +16,10 @@ struct SpotFindingSettings { // the online receiver and the FPGA path keep the single-pass fixed behaviour unless set otherwise. std::optional min_pix_per_spot = 2; int64_t max_pix_per_spot = 50; // Maximum pixels per spot - float high_resolution_limit = 2.0; + // High-resolution limit for spot finding [A]. std::nullopt = as far as the detector reaches, i.e. no + // resolution clipping of the detection at all (DiffractionExperiment::GetDetectorMaxResolution_A + // supplies the number where one is needed, e.g. for the spot plot's shells). + std::optional high_resolution_limit; float low_resolution_limit = 50.0; float cutoff_spot_count_low_res = 5.0; std::optional high_res_gap_Q_recipA = 1.5; // 0.25 * 2 * pi diff --git a/image_analysis/spot_finding/SpotUtils.cpp b/image_analysis/spot_finding/SpotUtils.cpp index b682649b..638e0c7c 100644 --- a/image_analysis/spot_finding/SpotUtils.cpp +++ b/image_analysis/spot_finding/SpotUtils.cpp @@ -147,7 +147,8 @@ void SpotAnalyze(const DiffractionExperiment &experiment, CountSpots(output, spots_out, spot_finding_settings.cutoff_spot_count_low_res); - GenerateSpotPlot(output, spots_out, spot_finding_settings.high_resolution_limit); + GenerateSpotPlot(output, spots_out, + spot_finding_settings.high_resolution_limit.value_or(experiment.GetDetectorMaxResolution_A())); output.resolution_estimate = GetResolution(spots_out); diff --git a/reader/HDF5MetadataSource.cpp b/reader/HDF5MetadataSource.cpp index 4a0cb2f3..75eac328 100644 --- a/reader/HDF5MetadataSource.cpp +++ b/reader/HDF5MetadataSource.cpp @@ -372,7 +372,6 @@ HDF5MetadataSource::OpenResult HDF5MetadataSource::Open(const std::string &filen dataset->b_factor = master_file->ReadOptVector("/entry/MX/bFactor"); dataset->image_scale_factor = master_file->ReadOptVector("/entry/MX/imageScaleFactor"); dataset->image_scale_cc = master_file->ReadOptVector("/entry/MX/imageScaleCC"); - dataset->image_scale_b = master_file->ReadOptVector("/entry/MX/imageScaleBFactor"); dataset->integrated_reflections = master_file->ReadOptVector("/entry/MX/integratedReflections"); } if (master_file->Exists("/entry/image")) @@ -951,8 +950,6 @@ void HDF5MetadataSource::FillPerImage(DataMessage &message, int64_t requested_im message.mosaicity_deg = dataset->mosaicity_deg[image_number]; if (dataset->b_factor.size() > image_number) message.b_factor = dataset->b_factor[image_number]; - if (dataset->image_scale_b.size() > image_number) - message.image_scale_b_factor = dataset->image_scale_b[image_number]; if (dataset->image_scale_factor.size() > image_number) message.image_scale_factor = dataset->image_scale_factor[image_number]; if (dataset->image_scale_cc.size() > image_number) diff --git a/reader/JFJochReaderDataset.h b/reader/JFJochReaderDataset.h index 5dcae787..c38ea2c1 100644 --- a/reader/JFJochReaderDataset.h +++ b/reader/JFJochReaderDataset.h @@ -49,7 +49,6 @@ struct JFJochReaderDataset { std::vector integrated_reflections; std::vector image_scale_factor; std::vector image_scale_cc; - std::vector image_scale_b; std::vector max_value; // Maps this dataset's image index -> the original/collected image number it came from. diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index b075daaf..f51c5e14 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -790,6 +790,13 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b result.cancelled = cancelled_; result.images_processed = finished_count.load(); + + // Every image failing is a total failure, not a run that produced nothing: it used to be reported + // only as per-image log lines while the process still exited 0 with no output file. + if (!cancelled_ && images_to_process > 0 && result.images_processed == 0) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "No image could be analyzed - see the per-image errors above"); + result.mean_processing_time = plots.GetMeanProcessingTime(); result.indexing_rate = plots.GetIndexingRate(); @@ -938,12 +945,11 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b const bool is_rotation = experiment_.IsRotationIndexing(); // rotation indexing -> rotation scaling/merge std::optional rsm; if (is_rotation) { - if (rot_ss.GetRefineB() - || experiment_.GetRefineRotationWedgeInScaling() + if (experiment_.GetRefineRotationWedgeInScaling() || rot_ss.GetRotationWedgeForScaling().has_value()) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Rotation scaling/merging (RotationScaleMerge) does not support " - "B-factor refinement or wedge refinement"); + "wedge refinement"); // A reference MTZ is allowed for rotation: it fixes the space group / cell (on the CLI) and // resolves the indexing ambiguity (below), but is NOT used to scale - the rotation merge stays // self-consistent. @@ -971,7 +977,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // degrade weak stills (measured CC1/2 collapse at the default 3 iters). { phase("Scaling images (" + label + ")"); - auto merge_result = MergeAll(experiment_, indexer->GetIntegrationOutcome(), false); + auto merge_result = MergeAll(experiment_, indexer->GetIntegrationOutcome()); indexer->ScaleAllImages(merge_result); } // Physical partiality post-refinement (default on; --simple-stills disables): refine a per-crystal @@ -994,19 +1000,22 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b merge_engine.MaskIceRings(masked_ice_rings, config_.spot_finding.ice_ring_width_Q_recipA); if (result.consensus_cell.has_value()) merge_engine.ReferenceCell(*result.consensus_cell); + // Merge-consistency filter: on floods with many spurious lattices (e.g. XFEL large cells) + // most indexed crystals do not correlate with the true structure; --min-image-cc drops the + // crystals whose per-image CC to the reference is below the limit, so the merge keeps only + // the coherent (real) ones. Set before the error model so the model, the merge and the + // reported statistics are all fitted over the same set of images. The P1 search pass keeps + // every image: its job is to find the symmetry, not to produce final intensities. + merge_engine.FilterByImageCC(!for_search + && experiment_.GetScalingSettings().GetMinCCForImage() > 0.0); merge_engine.RefineErrorModel(merge_input); if (merge_engine.ErrorModelActive()) logger.Info("Error model: a={:.3f} b={:.3f} ISa={:.1f} chi2={:.2f}", merge_engine.ErrorModelA(), merge_engine.ErrorModelB(), merge_engine.ErrorModelB() > 0 ? 1.0 / merge_engine.ErrorModelB() : 0.0, merge_engine.ErrorModelChi2()); - // Merge-consistency filter: on floods with many spurious lattices (e.g. XFEL large cells) - // most indexed crystals do not correlate with the true structure; --min-image-cc drops the - // crystals whose per-image CC to the reference is below the limit, so the merge keeps only - // the coherent (real) ones. - const bool cc_filter = !for_search && experiment_.GetScalingSettings().GetMinCCForImage() > 0.0; for (size_t i = 0; i < merge_input.size(); ++i) - merge_engine.AddImage(merge_input[i], static_cast(i), cc_filter); + merge_engine.AddImage(merge_input[i], static_cast(i)); ScaleMergeResult out; out.merged = merge_engine.ExportReflections(); diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 751821fe..13374fa2 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -76,7 +76,10 @@ std::string RugnuxCommandLine(const ProcessConfig &config, if (azint) { const auto a = experiment.GetAzimuthalIntegrationSettings(); add("--azim-min-q", num(a.GetLowQ_recipA())); - add("--azim-max-q", num(a.GetHighQ_recipA())); + // An unset maximum Q means "to the detector edge"; emitting the resolved number would pin it + // to this run's geometry, so leave the flag out and let it resolve again. + if (const auto high_q = a.GetRequestedHighQ_recipA()) + add("--azim-max-q", num(*high_q)); add("--azim-q-spacing", num(a.GetQSpacing_recipA())); add("--azim-phi-bins", std::to_string(a.GetAzimuthalBinCount())); add("--polarization-correction", a.IsPolarizationCorrection() ? "on" : "off"); @@ -85,13 +88,18 @@ std::string RugnuxCommandLine(const ProcessConfig &config, const auto &sf = config.spot_finding; add("--spot-sigma", num(sf.signal_to_noise_threshold)); add("--spot-threshold", std::to_string(sf.photon_count_threshold)); + // The adaptive-detection default depends on the workflow (on for stills, off for rotation), so + // always emit the explicit flag rather than relying on it. + args.emplace_back(sf.adaptive_threshold ? "--adaptive-spots" : "--no-adaptive-spots"); if (sf.adaptive_threshold) add("--spot-false-pixels", num(sf.false_pixels_per_frame)); // min-pix is chosen per image unless an explicit value is given, so emit --min-pix-per-spot only // when a fixed min-pix was selected; its absence selects the adaptive per-image path. if (sf.min_pix_per_spot.has_value()) add("--min-pix-per-spot", std::to_string(*sf.min_pix_per_spot)); - add("--spot-high-resolution", num(sf.high_resolution_limit)); + // Same for the spot-finding limit: absent means "as far as the detector reaches". + if (sf.high_resolution_limit.has_value()) + add("--spot-high-resolution", num(*sf.high_resolution_limit)); add("--max-spots", std::to_string(experiment.GetMaxSpotCount())); const auto idx = experiment.GetIndexingSettings(); @@ -151,8 +159,6 @@ std::string RugnuxCommandLine(const ProcessConfig &config, const auto sc = experiment.GetScalingSettings(); if (!sc.GetMergeFriedel()) args.emplace_back("-A"); - if (sc.GetRefineB()) - args.emplace_back("-B"); if (!sc.GetStillsPartialityRefine()) args.emplace_back("--simple-stills"); if (!sc.GetExpectedVarianceMerge()) diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 15b579d7..d1e1e66f 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -66,9 +66,10 @@ void print_usage() { std::cout << " --spot-sigma Noise sigma level for spot finding (default: 3.0)" << std::endl; std::cout << " --spot-threshold Photon count threshold for spot finding (default: 10)" << std::endl; std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot. If omitted, min-pix is chosen PER IMAGE (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction is kept. Give an explicit value to force a fixed min-pix instead." << std::endl; - std::cout << " --adaptive-spots Self-calibrating detection: replace the fixed --spot-threshold with a per-resolution-ring threshold set from each image's own noise, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)" << std::endl; + std::cout << " --adaptive-spots Self-calibrating detection (DEFAULT for stills): the strong-pixel threshold comes from each image's own per-resolution-ring noise instead of the fixed --spot-threshold, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning). Rotation data keeps the fixed-threshold finder unless this is given." << std::endl; + std::cout << " --no-adaptive-spots Turn adaptive detection off and use the fixed --spot-threshold / --spot-sigma finder instead" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; - std::cout << " --spot-high-resolution High resolution limit for spot finding (default: 1.5)" << std::endl; + std::cout << " --spot-high-resolution High resolution limit for spot finding. If omitted, stills extend as far as the detector reaches (no resolution clipping) and rotation data keeps a 1.5 A limit." << std::endl; std::cout << " --spot-low-resolution Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl; std::cout << " --max-spots Max spot count (default: 250)" << std::endl; std::cout << " --detect-ice-rings[=on|off] Flag ice-ring spots (de-prioritised in indexing) and exclude ice-ring reflections from scaling/merging; overrides the dataset/master-file setting (default: use dataset value)" << std::endl; @@ -99,7 +100,6 @@ void print_usage() { std::cout << " --no-scaling-corrections rot3d: disable the (default-on) decay + absorption correction surfaces fitted on the fulls after scale-fulls" << std::endl; std::cout << " --no-expected-variance-merge stills: disable the default expected-variance merge weighting (which rebuilds each weak observation's signal variance at the reflection mean to de-bias the inverse-variance merge); restores observed-sigma weighting" << std::endl; std::cout << " -A, --anomalous Anomalous mode (don't merge Friedel pairs)" << std::endl; - std::cout << " -B, --refine-bfactor Refine per image B-factor (stills only)" << std::endl; std::cout << " --scaling-high-resolution High resolution limit for scaling/merging (manual override; default: no limit)" << std::endl; std::cout << " --resolution-cutoff Automatic high-resolution cutoff for the written reflections + reported shells: cc-logistic|off (default: cc-logistic; ignored when --scaling-high-resolution is set)" << std::endl; std::cout << " --resolution-cc-target CC1/2 target defining the cc-logistic fall-off (default: 0.30)" << std::endl; @@ -124,7 +124,7 @@ void print_usage() { std::cout << " --simple-stills stills: treat every reflection as a full (p=1, single-pass scale/merge); disables the default physical partiality post-refinement" << std::endl; std::cout << " -q, --azim-q-spacing Azimuthal-integration Q bin spacing (1/A) (default: 0.01)" << std::endl; std::cout << " --azim-min-q Azimuthal-integration minimum Q (1/A)" << std::endl; - std::cout << " --azim-max-q Azimuthal-integration maximum Q (1/A)" << std::endl; + std::cout << " --azim-max-q Azimuthal-integration maximum Q (1/A). If omitted, integration extends to the highest Q the detector reaches." << std::endl; std::cout << " --azim-phi-bins Number of azimuthal (phi) bins (default: 1)" << std::endl; std::cout << " --polarization-correction Enable/disable azimuthal polarization correction" << std::endl; std::cout << " --solid-angle-correction Enable/disable azimuthal solid angle correction" << std::endl; @@ -145,6 +145,7 @@ enum { OPT_SPOT_THRESHOLD, OPT_MIN_PIX_PER_SPOT, OPT_ADAPTIVE_SPOTS, + OPT_NO_ADAPTIVE_SPOTS, OPT_SPOT_FALSE_PIXELS, OPT_SPOT_RESOLUTION, OPT_SPOT_LOW_RESOLUTION, @@ -214,7 +215,6 @@ static option long_options[] = { {"dump-observations", required_argument, nullptr, OPT_DUMP_OBSERVATIONS}, {"space-group", required_argument, nullptr, 'S'}, {"anomalous", no_argument, nullptr, 'A'}, - {"refine-bfactor", no_argument, nullptr, 'B'}, {"azint-only", no_argument, nullptr, OPT_AZINT_ONLY}, {"scale", no_argument, nullptr, OPT_SCALE}, {"no-merge", no_argument, nullptr, OPT_NO_MERGE}, @@ -253,6 +253,7 @@ static option long_options[] = { {"spot-threshold", required_argument, nullptr, OPT_SPOT_THRESHOLD}, {"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT}, {"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS}, + {"no-adaptive-spots", no_argument, nullptr, OPT_NO_ADAPTIVE_SPOTS}, {"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS}, {"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION}, {"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION}, @@ -515,9 +516,8 @@ int main(int argc, char **argv) { float sigma_spot_finding = 3.0; int64_t photon_count_threshold_spot_finding = 10; std::optional min_pix_per_spot; // unset -> adaptive per image; a value -> fixed min-pix - bool adaptive_spots = false; + std::optional adaptive_spots; // unset -> per-workflow default (stills on, rotation off) float false_pixels_per_frame = 100.0f; - bool refine_bfactor = false; std::string ref_mtz; std::string ref_column; std::string model_pdb; // --model: PDB to validate merged intensities against (R-free + maps) @@ -537,7 +537,7 @@ int main(int argc, char **argv) { IndexingAlgorithmEnum indexing_algorithm = IndexingAlgorithmEnum::Auto; GeomRefinementAlgorithmEnum refinement_algorithm = GeomRefinementAlgorithmEnum::BeamCenter; - float d_min_spot_finding = 1.5; + std::optional d_min_spot_finding; // unset -> as far as the detector reaches float d_max_spot_finding = 0; // 0 = keep the SpotFindingSettings default (50 A) std::optional d_min_scale_merge; std::optional resolution_cutoff_method; // --resolution-cutoff cc-logistic|off @@ -556,7 +556,7 @@ int main(int argc, char **argv) { int opt; int option_index = 0; - const char *short_opts = "vo:N:s:e:t:R::X:C:z:FABS:r:q:"; + const char *short_opts = "vo:N:s:e:t:R::X:C:z:FAS:r:q:"; while ((opt = getopt_long(argc, argv, short_opts, long_options, &option_index)) != -1) { switch (opt) { @@ -715,9 +715,6 @@ int main(int argc, char **argv) { case 'A': anomalous_mode = true; break; - case 'B': - refine_bfactor = true; - break; case 'S': { // Accept a space-group number ("92") or a Hermann-Mauguin symbol ("P43212", "P 43 21 2"). char *end = nullptr; @@ -750,6 +747,10 @@ int main(int argc, char **argv) { adaptive_spots = true; logger.Info("Adaptive (self-calibrating) spot detection enabled"); break; + case OPT_NO_ADAPTIVE_SPOTS: + adaptive_spots = false; + logger.Info("Adaptive spot detection off: using the fixed --spot-threshold / --spot-sigma finder"); + break; case OPT_SPOT_FALSE_PIXELS: false_pixels_per_frame = parse_number_arg(optarg, "--spot-false-pixels", logger, 1.0f); adaptive_spots = true; @@ -759,10 +760,20 @@ int main(int argc, char **argv) { d_max_spot_finding = parse_number_arg(optarg, "--spot-low-resolution", logger, 0.0f); logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding); break; - case OPT_SPOT_RESOLUTION: - d_min_spot_finding = parse_number_arg(optarg, "--spot-high-resolution", logger, 0.0f); - logger.Info("High resolution limit for spot finding set to {:.2f} A", d_min_spot_finding); + case OPT_SPOT_RESOLUTION: { + // 0 has always meant "no limit" for this setting; keep that, but express it as the unset + // optional the rest of the code understands. Passing the 0 through instead reached + // ResolutionShells (via the spot plot), which rejects a zero d_min and threw away every image. + const auto d_min = parse_number_arg(optarg, "--spot-high-resolution", logger, 0.0f); + if (d_min > 0.0f) { + d_min_spot_finding = d_min; + logger.Info("High resolution limit for spot finding set to {:.2f} A", d_min); + } else { + d_min_spot_finding.reset(); + logger.Info("No high resolution limit for spot finding: as far as the detector reaches"); + } break; + } case OPT_MAX_SPOTS: max_spot_count_override = parse_number_arg(optarg, "--max-spots", logger, 1); break; @@ -1074,7 +1085,6 @@ int main(int argc, char **argv) { if (resolution_cc_target) scaling_settings.ResolutionCCTarget(*resolution_cc_target); if (report_shell_count) scaling_settings.ReportShellCount(*report_shell_count); scaling_settings.MergeFriedel(!anomalous_mode); - scaling_settings.RefineB(refine_bfactor); scaling_settings.MinPartiality(min_partiality); scaling_settings.MinCapturedFraction(min_captured_fraction_arg.value_or( (experiment.GetGoniometer().has_value() && !force_still) ? 0.7 : 0.0)); @@ -1112,16 +1122,16 @@ int main(int argc, char **argv) { // Rotation (rot3d): the dedicated RotationScaleMerge does the whole self-scale -> 3D combine -> // merge, including the default-on decay + absorption correction surfaces. It does not support - // external-reference scaling, the stills -B (per-image B-factor) or wedge refinement. + // external-reference scaling or wedge refinement. // Everything else (stills, reference scaling) uses ScaleOnTheFly + MergeOnTheFly. const bool is_rotation = experiment.IsRotationIndexing(); if (is_rotation) { - if (!reference_data.empty() || experiment.GetScalingSettings().GetRefineB() + if (!reference_data.empty() || experiment.GetRefineRotationWedgeInScaling() || experiment.GetScalingSettings().GetRotationWedgeForScaling().has_value()) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Rotation scaling/merging (RotationScaleMerge) does not support reference " - "scaling, B-factor refinement or wedge refinement"); + "scaling or wedge refinement"); RotationScaleMerge rsm(experiment, reflections, experiment.GetUnitCell(), scaling_iter, 0.0f, nthreads, logger); rsm.Ingest(); @@ -1146,6 +1156,8 @@ int main(int argc, char **argv) { } MergeOnTheFly merge_engine(experiment); merge_engine.ReferenceCell(experiment.GetUnitCell()); + // --min-image-cc has to hold for the merge itself, not only for the reported statistics. + merge_engine.FilterByImageCC(experiment.GetScalingSettings().GetMinCCForImage() > 0.0); // Fit the (a, b) error model from symmetry-mate scatter before merging, exactly as the full // pipeline does (Rugnux.cpp). Without this the offline --scale merge would use the identity // model and produce much worse stills intensities (no (b*I)^2 systematic term, no sigma floor). @@ -1262,7 +1274,7 @@ int main(int argc, char **argv) { AzimuthalIntegrationSettings azint_settings = experiment.GetAzimuthalIntegrationSettings(); if (min_q || max_q) azint_settings.QRange_recipA(min_q.value_or(azint_settings.GetLowQ_recipA()), - max_q.value_or(azint_settings.GetHighQ_recipA())); + max_q ? max_q : azint_settings.GetRequestedHighQ_recipA()); if (q_spacing) azint_settings.QSpacing_recipA(q_spacing.value()); if (azimuthal_bins) @@ -1398,7 +1410,6 @@ int main(int argc, char **argv) { if (resolution_cc_target) scaling_settings.ResolutionCCTarget(*resolution_cc_target); if (report_shell_count) scaling_settings.ReportShellCount(*report_shell_count); scaling_settings.MergeFriedel(!anomalous_mode); - scaling_settings.RefineB(refine_bfactor); scaling_settings.MinPartiality(min_partiality); // Drop edge-of-sweep truncated fulls (rocking curve captured < this fraction) from the rot3d combine. // Defaults ON (0.7) for rotation - removes the low-capture fulls that inflate low-res R-meas and @@ -1468,14 +1479,25 @@ int main(int argc, char **argv) { SpotFindingSettings spot_settings; spot_settings.enable = true; spot_settings.indexing = true; - spot_settings.high_resolution_limit = d_min_spot_finding; spot_settings.signal_to_noise_threshold = sigma_spot_finding; spot_settings.photon_count_threshold = photon_count_threshold_spot_finding; + // Detection defaults differ by workflow; each is overridden by its flag, which always wins. + // - min-pix: choosing it per image (unset) only means something where each frame is indexed on its + // own. Rotation indexing builds ONE lattice from all frames, so it keeps the fixed value. + // - adaptive detection: a clear win across the stills battery, but on the 33-crystal rotation + // battery it deterministically breaks three (a lost space group, a halved indexing rate, a + // collapsed merge) while helping four, so rotation keeps the fixed-threshold finder. + // - high-resolution limit: unset means "as far as the detector reaches", which is what the stills + // path wants; on rotation the extra high-resolution spots cost indexing (measured 100.0 -> 96.8% + // on a strong rotation set), so rotation keeps the historical limit. spot_settings.min_pix_per_spot = min_pix_per_spot; - spot_settings.adaptive_threshold = adaptive_spots; + if (rotation_indexing && !spot_settings.min_pix_per_spot.has_value()) + spot_settings.min_pix_per_spot = 2; + if (rotation_indexing && !d_min_spot_finding.has_value()) + d_min_spot_finding = 1.5f; + spot_settings.adaptive_threshold = adaptive_spots.value_or(!rotation_indexing); + spot_settings.high_resolution_limit = d_min_spot_finding; spot_settings.false_pixels_per_frame = false_pixels_per_frame; - if (d_min_spot_finding > 0.0f) - spot_settings.high_resolution_limit = d_min_spot_finding; if (d_max_spot_finding > 0.0f) spot_settings.low_resolution_limit = d_max_spot_finding; diff --git a/tests/AdaptiveThresholdTest.cpp b/tests/AdaptiveThresholdTest.cpp new file mode 100644 index 00000000..ecd13da6 --- /dev/null +++ b/tests/AdaptiveThresholdTest.cpp @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include + +#include "../image_analysis/spot_finding/AdaptiveThreshold.h" + +using namespace adaptive_threshold; + +namespace { + // Poisson upper tail P(X >= k) for mean mu, summed directly - an independent reference for the + // threshold's defining property. + double PoissonUpperTail(double mu, int k) { + if (k <= 0) + return 1.0; + double pmf = std::exp(-mu); + double cdf = pmf; + for (int i = 1; i < k; i++) { + pmf *= mu / i; + cdf += pmf; + } + return std::max(0.0, 1.0 - cdf); + } +} + +TEST_CASE("AdaptiveThreshold_NormalQuantile", "[SpotFinding]") { + // Textbook values of the inverse standard-normal CDF. + CHECK(NormalQuantile(0.5) == Catch::Approx(0.0).margin(1e-9)); + CHECK(NormalQuantile(0.975) == Catch::Approx(1.959964).margin(1e-5)); + CHECK(NormalQuantile(0.99) == Catch::Approx(2.326348).margin(1e-5)); + CHECK(NormalQuantile(1.0 - 1e-6) == Catch::Approx(4.753424).margin(1e-4)); + + // Symmetric about 0.5, and monotonically increasing. + for (const double p: {1e-8, 1e-4, 0.01, 0.2, 0.45}) + CHECK(NormalQuantile(1.0 - p) == Catch::Approx(-NormalQuantile(p)).margin(1e-6)); + CHECK(NormalQuantile(0.6) > NormalQuantile(0.55)); + CHECK(NormalQuantile(1e-3) < NormalQuantile(1e-2)); + + // Degenerate arguments stay finite: the finders divide a tolerated-false-pixel count by the pixel + // count, so p can legitimately arrive at the very edge of (0, 1). + CHECK(std::isfinite(NormalQuantile(0.0))); + CHECK(std::isfinite(NormalQuantile(1.0))); + CHECK(NormalQuantile(0.0) < 0.0); + CHECK(NormalQuantile(1.0) > 0.0); +} + +TEST_CASE("AdaptiveThreshold_PoissonThreshold", "[SpotFinding]") { + const double p = 1e-5; + const float z = static_cast(NormalQuantile(1.0 - p)); + + // The defining property: the returned count is the SMALLEST whose upper tail is within p. + for (const double mu: {1e-6, 0.1, 1.0, 3.0, 10.0, 40.0}) { + const int thr = static_cast(PoissonThreshold(mu, p, z)); + CHECK(PoissonUpperTail(mu, thr) <= p); + CHECK(PoissonUpperTail(mu, thr - 1) > p); + } + + // Non-decreasing in the background level. + float prev = 0.0f; + for (const double mu: {1e-6, 0.01, 0.1, 0.5, 1.0, 2.0, 5.0, 20.0, 45.0}) { + const float thr = PoissonThreshold(mu, p, z); + CHECK(thr >= prev); + prev = thr; + } + + // Above mu = 50 it short-circuits to the Gaussian form mu + z sqrt(mu). + CHECK(PoissonThreshold(100.0, p, z) == Catch::Approx(100.0 + z * 10.0).epsilon(1e-5)); + + // A tighter operating point (smaller p) can only raise the threshold. + CHECK(PoissonThreshold(5.0, 1e-8, static_cast(NormalQuantile(1.0 - 1e-8))) + >= PoissonThreshold(5.0, 1e-2, static_cast(NormalQuantile(1.0 - 1e-2)))); +} + +TEST_CASE("AdaptiveThreshold_RingThreshold", "[SpotFinding]") { + const double p = 1e-5; + const float z = static_cast(NormalQuantile(1.0 - p)); + + // Never below the read-noise-aware Gaussian arm, which is what keeps an empty ring's threshold + // off zero - a per-ring sigma alone would collapse there and flood the frame with noise spots. + for (const float mean: {0.0f, 0.5f, 5.0f, 50.0f}) { + for (const float sigma: {0.0f, 1.0f, 7.0f}) { + const float gauss = mean + z * std::sqrt(sigma * sigma + READ * READ); + CHECK(RingThreshold(mean, sigma, p, z) >= Catch::Approx(gauss).epsilon(1e-6)); + } + } + CHECK(RingThreshold(0.0f, 0.0f, p, z) >= z * READ); + + // Non-decreasing in the background mean and in the background scatter. + CHECK(RingThreshold(20.0f, 4.0f, p, z) > RingThreshold(2.0f, 4.0f, p, z)); + CHECK(RingThreshold(5.0f, 9.0f, p, z) > RingThreshold(5.0f, 1.0f, p, z)); + + // Where the background is countable and quiet, Poisson significance is the binding arm: a ring + // with mean 1 and no measured scatter must still demand several photons. + CHECK(RingThreshold(1.0f, 0.0f, p, z) > 1.0f + z * READ); + + // A ring whose scatter is far above Poisson (flat-field / read excess) is set by the Gaussian arm. + CHECK(RingThreshold(10.0f, 30.0f, p, z) == Catch::Approx(10.0f + z * std::sqrt(900.0f + READ * READ)).epsilon(1e-6)); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0dade2aa..9145a49b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ ADD_EXECUTABLE(jfjoch_test CheckImageOutput.h FPGAIntegrationTest.cpp StrongPixelSetTest.cpp + AdaptiveThresholdTest.cpp ZSTDCompressorTest.cpp FrameTransformationTest.cpp HDF5WritingTest.cpp PedestalCalcTest.cpp @@ -64,6 +65,7 @@ ADD_EXECUTABLE(jfjoch_test ImageSpotFinderCPUTest.cpp ImageSpotFinderGPUTest.cpp AdaptiveSpotFinderGPUTest.cpp + AdaptiveThresholdTest.cpp CalcBraggPredictionTest.cpp SpotUtilsTest.cpp LatticeSearchTest.cpp diff --git a/tests/JFJochReaderTest.cpp b/tests/JFJochReaderTest.cpp index 68243fcf..e84d6a25 100644 --- a/tests/JFJochReaderTest.cpp +++ b/tests/JFJochReaderTest.cpp @@ -985,6 +985,9 @@ TEST_CASE("JFJochReader_Azint", "[HDF5][Full]") { AzimuthalIntegrationSettings azint_settings; azint_settings.AzimuthalBinCount(4); x.ImportAzimuthalIntegrationSettings(azint_settings); + // The high-q limit is unset, i.e. "as far as the detector reaches", so read the settings back from + // the experiment, where that has been resolved against the geometry - that is what the bins are. + azint_settings = x.GetAzimuthalIntegrationSettings(); std::vector image(x.GetPixelsNum()); @@ -1544,6 +1547,9 @@ TEST_CASE("JFJochReader_NXmxIntegrated", "[HDF5][Full]") { AzimuthalIntegrationSettings azint_settings; azint_settings.AzimuthalBinCount(4); x.ImportAzimuthalIntegrationSettings(azint_settings); + // The high-q limit is unset, i.e. "as far as the detector reaches", so read the settings back from + // the experiment, where that has been resolved against the geometry - that is what the bins are. + azint_settings = x.GetAzimuthalIntegrationSettings(); std::vector image(x.GetPixelsNum(), 0); image[0] = UINT16_MAX; diff --git a/tests/XDSPluginTest.cpp b/tests/XDSPluginTest.cpp index 926893bf..bd23b444 100644 --- a/tests/XDSPluginTest.cpp +++ b/tests/XDSPluginTest.cpp @@ -191,6 +191,8 @@ TEST_CASE("XDSPlugin_GetData_Integrated", "[HDF5][XDS][Plugin]") { AzimuthalIntegrationSettings azint_settings; azint_settings.AzimuthalBinCount(4); x.ImportAzimuthalIntegrationSettings(azint_settings); + // Unset high q = "as far as the detector reaches"; the experiment resolves it against the geometry. + azint_settings = x.GetAzimuthalIntegrationSettings(); std::vector image(x.GetPixelsNum(), 0); image[0] = UINT16_MAX; diff --git a/update_version.sh b/update_version.sh index e29f57d0..903c529b 100644 --- a/update_version.sh +++ b/update_version.sh @@ -4,6 +4,11 @@ # Copyright (2019-2024) Paul Scherrer Institute # +# Stop at the first failing command (this script rewrites generated code and version +# strings in place, so a silently skipped step leaves the tree half-updated). +set -e +set -o pipefail + VERSION=$( docs/THIRD_PARTY_NOTICES.md -git add broker/gen/model/*.cpp broker/gen/model/*.h frontend/src/openapi/models/*.ts docs/python_client/*.md docs/python_client/docs/*.md docs/THIRD_PARTY_NOTICES.md +git add broker/gen/model/*.cpp broker/gen/model/*.h frontend/src/client docs/python_client/*.md docs/python_client/docs/*.md docs/THIRD_PARTY_NOTICES.md sed -i "s,release =.*,release = \'$VERSION\'," docs/conf.py -sed -i "s,\"version\":.*,\"version\": \"$VERSION\"\,," frontend/package.json cd fpga diff --git a/viewer/JFJochHttpReader.cpp b/viewer/JFJochHttpReader.cpp index 4385297d..95210bf2 100644 --- a/viewer/JFJochHttpReader.cpp +++ b/viewer/JFJochHttpReader.cpp @@ -281,7 +281,6 @@ std::shared_ptr JFJochHttpReader::UpdateDataset_i() { dataset->integrated_reflections = GetPlot_i("integrated_reflections"); dataset->image_scale_factor = GetPlot_i("image_scale_factor"); dataset->image_scale_cc = GetPlot_i("image_scale_cc"); - dataset->image_scale_b = GetPlot_i("image_scale_b"); if (msg->start_message->goniometer) dataset->experiment.Goniometer(msg->start_message->goniometer); diff --git a/viewer/JFJochProcessController.cpp b/viewer/JFJochProcessController.cpp index 8b7c1016..8301737a 100644 --- a/viewer/JFJochProcessController.cpp +++ b/viewer/JFJochProcessController.cpp @@ -119,7 +119,6 @@ void JFJochProcessController::OnImageProcessed(const DataMessage &msg) { if (msg.integrated_reflections) put(d.integrated_reflections, *msg.integrated_reflections); if (msg.image_scale_factor) put(d.image_scale_factor, *msg.image_scale_factor); if (msg.image_scale_cc) put(d.image_scale_cc, *msg.image_scale_cc); - if (msg.image_scale_b_factor) put(d.image_scale_b, *msg.image_scale_b_factor); // Throttle to ~4 Hz so the GUI plots refresh smoothly without flooding the event queue. const auto now = std::chrono::steady_clock::now(); diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 56ea0a8f..3501c328 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -85,7 +85,12 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString SpotFindingSettings spot_finding_settings = DiffractionExperiment::DefaultDataProcessingSettings(); - spot_finding_settings.high_resolution_limit = 1.5; + // The interactive viewer is an offline tool looking at one dataset at a time, so it starts from the + // self-calibrating settings rather than numbers the user would have to tune: the per-ring adaptive + // threshold, min-pix chosen per image (std::nullopt), and detection out to the detector edge + // (high_resolution_limit unset). All three are switchable in the settings dock. + spot_finding_settings.adaptive_threshold = true; + spot_finding_settings.min_pix_per_spot = std::nullopt; spot_finding_settings.indexing = true; IndexingSettings indexing_settings; diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 267415c5..44c6085f 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -1030,11 +1030,6 @@ void JFJochDiffractionImage::DrawResolutionText() { viewport()->update(dirty); } -void JFJochDiffractionImage::beforeOverlayCleared() { - // The resolution readout is painted in drawForeground(), not held as a scene item, so - // clearing the overlay (or the whole scene) cannot leave a dangling pointer behind. -} - void JFJochDiffractionImage::leaveEvent(QEvent *event) { // Mouse left the view: clear hover resolution and hide text if (std::isfinite(hover_resolution)) { diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index f3998be1..f4cc0ad8 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -87,7 +87,6 @@ private: void DrawCross(float x, float y, float size, float width, float z = 1); void UpdateForeground(); - void beforeOverlayCleared() override; void leaveEvent(QEvent *event) override; std::shared_ptr image; diff --git a/viewer/image_viewer/JFJochFollowerImage.cpp b/viewer/image_viewer/JFJochFollowerImage.cpp index 95761db5..fd113cb9 100644 --- a/viewer/image_viewer/JFJochFollowerImage.cpp +++ b/viewer/image_viewer/JFJochFollowerImage.cpp @@ -33,7 +33,6 @@ JFJochFollowerImage::JFJochFollowerImage(QWidget *parent) : QGraphicsView(parent void JFJochFollowerImage::SetFrame(std::shared_ptr frame) { const bool same_pointer = (frame_ == frame); - const bool same_size = frame_ && frame && frame_->size() == frame->size(); frame_ = std::move(frame); if (!frame_ || frame_->isNull()) { @@ -49,9 +48,12 @@ void JFJochFollowerImage::SetFrame(std::shared_ptr frame) { scene()->addItem(item_); } - if (!same_size) { + // The producer reassigns its buffer in place when the frame size changes, so the pointer says + // nothing about the dimensions - compare against the size we last saw. + if (frame_->size() != frame_size_) { + frame_size_ = frame_->size(); item_->refresh(); - scene()->setSceneRect(0, 0, frame_->width(), frame_->height()); + scene()->setSceneRect(0, 0, frame_size_.width(), frame_size_.height()); } viewport()->update(); diff --git a/viewer/image_viewer/JFJochFollowerImage.h b/viewer/image_viewer/JFJochFollowerImage.h index 90bd5f5d..bcafb990 100644 --- a/viewer/image_viewer/JFJochFollowerImage.h +++ b/viewer/image_viewer/JFJochFollowerImage.h @@ -30,6 +30,7 @@ class JFJochFollowerImage : public QGraphicsView { JFJochImageItem *item_ = nullptr; std::shared_ptr frame_; + QSize frame_size_; // size the scene rect was set from std::shared_ptr values_; double zoom_ = 12.0; diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 9fb40eba..25077bae 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -94,6 +94,12 @@ void JFJochImage::ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModif hover_tail_timer_->start(kHoverIntervalMs); } +void JFJochImage::leaveEvent(QEvent *event) { + // A tail update that fires now would report a position the pointer has already left + hover_tail_timer_->stop(); + QGraphicsView::leaveEvent(event); +} + void JFJochImage::ScheduleRenderImage() { if (render_pending_) return; @@ -900,8 +906,6 @@ void JFJochImage::resetScenePointers() { void JFJochImage::updateOverlay() { if (!scene() || W * H <= 0) return; - beforeOverlayCleared(); - // Remove only overlay items, keep the image item persistent for (auto *item : overlay_items_) scene()->removeItem(item); @@ -975,8 +979,6 @@ void JFJochImage::adjustForeground(bool input) { m_adjustForegroundWithWheel = input; } -void JFJochImage::beforeOverlayCleared() {} - double JFJochImage::GetScaleFactor() const { return scale_factor; } diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index 23f8ddba..b3ead6b1 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -95,7 +95,6 @@ protected: // Only the view that owns detector counts offers a region of interest; for the others a // shift-drag would draw a box that means nothing. [[nodiscard]] virtual bool AllowROI() const { return false; } - virtual void beforeOverlayCleared(); bool show_saturation = false; @@ -178,6 +177,7 @@ protected: static constexpr int kHoverIntervalMs = 66; void ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers); void UpdateHover(); + void leaveEvent(QEvent *event) override; QPointF hover_scene_pos_; Qt::KeyboardModifiers hover_modifiers_ = Qt::NoModifier; QElapsedTimer hover_rate_; diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index 3203b55b..25c3c385 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -55,7 +55,9 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp const BraggIntegrationSettings &bragg, const ScalingSettings &scaling, QWidget *parent) - : QWidget(parent), spot_(spot), indexing_(indexing), azint_(azint), bragg_(bragg), scaling_(scaling) { + : QWidget(parent), spot_(spot), indexing_(indexing), azint_(azint), bragg_(bragg), scaling_(scaling), + adaptive_min_pix_(!spot.min_pix_per_spot.has_value()), + min_pix_value_(spot.min_pix_per_spot.value_or(2)) { auto *layout = new QVBoxLayout(this); @@ -228,19 +230,28 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { adaptive->setChecked(spot_.adaptive_threshold); adaptive->setToolTip("Self-calibrating detection: the strong-pixel threshold is derived from each " "image's own per-resolution-ring noise. The signal/noise and photon-count " - "settings are not used while this is on."); + "settings are not used while this is on. Best on stills; on rotation data the " + "fixed-threshold finder is the safer choice, which is what rugnux defaults to " + "there."); auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page); - highResSpot->setValue(spot_.high_resolution_limit); - auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(spot_.min_pix_per_spot.value_or(2)), 0, "px", page); - auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (per image)", page); - adaptiveMinPix->setChecked(!spot_.min_pix_per_spot.has_value()); - adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image (stills indexing): index at " - "min-pix 3/2/1 and keep whichever maximises indexed count x indexed " - "fraction. The fixed min-pixels/spot value is not used while this is on."); + highResSpot->setValue(spot_.high_resolution_limit.value_or(1.5f)); + auto *autoHighResSpot = new QCheckBox("To detector edge", page); + autoHighResSpot->setChecked(!spot_.high_resolution_limit.has_value()); + autoHighResSpot->setToolTip("Find spots as far as the detector reaches, instead of clipping the " + "detection at a fixed resolution. The high-resolution value is not used " + "while this is on."); + auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(min_pix_value_), 0, "px", page); + auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (stills)", page); + adaptiveMinPix->setChecked(adaptive_min_pix_); + adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image: index the frame at min-pix " + "3/2/1 and keep whichever maximises indexed count x indexed fraction. " + "Stills only - a rotation dataset builds one lattice from all frames and " + "always uses the fixed min-pixels/spot value below."); auto *maxSpots = new NumberLineEdit(10.0f, 100000.0f, static_cast(max_spots_), 0, "", page); spot->addRow("", adaptive); spot->addRow("Signal/noise", snr); spot->addRow("Photon count", count); + spot->addRow("", autoHighResSpot); spot->addRow("High resolution [Å]", highResSpot); spot->addRow("", adaptiveMinPix); spot->addRow("Min pixels/spot", minPix); @@ -255,7 +266,7 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { connect(highResSpot, &SliderPlusBox::valueChanged, this, [this](double v) { spot_.high_resolution_limit = static_cast(v); EmitSpotFinding(); }); connect(minPix, &NumberLineEdit::newValue, this, [this, minPix] { - spot_.min_pix_per_spot = std::llround(minPix->value()); EmitSpotFinding(); }); + min_pix_value_ = std::llround(minPix->value()); EmitSpotFinding(); }); connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] { max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); }); // The adaptive finder sets its own threshold from each image's noise, so the signal/noise and @@ -270,16 +281,23 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { syncAdaptiveEnabled(on); EmitSpotFinding(); }); - // Adaptive min-pix chooses the value per image (min_pix_per_spot = std::nullopt), so the fixed - // min-pixels/spot field is unused while it is on - grey it out to make that clear. - auto syncMinPixEnabled = [minPix](bool adaptive_on) { minPix->setEnabled(!adaptive_on); }; - syncMinPixEnabled(!spot_.min_pix_per_spot.has_value()); - connect(adaptiveMinPix, &QCheckBox::toggled, this, [this, minPix, syncMinPixEnabled](bool on) { + // An unset high-resolution limit (std::nullopt) means "to the detector edge", so the value is unused + // while that is on - grey it out, as for the other automatic settings. + auto syncHighResEnabled = [highResSpot](bool auto_on) { highResSpot->setEnabled(!auto_on); }; + syncHighResEnabled(!spot_.high_resolution_limit.has_value()); + connect(autoHighResSpot, &QCheckBox::toggled, this, [this, highResSpot, syncHighResEnabled](bool on) { if (on) - spot_.min_pix_per_spot = std::nullopt; + spot_.high_resolution_limit = std::nullopt; else - spot_.min_pix_per_spot = std::llround(minPix->value()); - syncMinPixEnabled(on); + spot_.high_resolution_limit = static_cast(highResSpot->value()); + syncHighResEnabled(on); + EmitSpotFinding(); + }); + + // The fixed min-pixels/spot field stays live even with adaptive min-pix on: it is what a rotation + // dataset uses, and what stills fall back to when adaptive is switched off. + connect(adaptiveMinPix, &QCheckBox::toggled, this, [this](bool on) { + adaptive_min_pix_ = on; EmitSpotFinding(); }); @@ -395,6 +413,10 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { lowQ->setValue(azint_.GetLowQ_recipA()); auto *highQ = new SliderPlusBox(2e-5, 10.0, 0.001, 4, page); highQ->setValue(azint_.GetHighQ_recipA()); + auto *autoHighQ = new QCheckBox("To detector edge", page); + autoHighQ->setChecked(!azint_.GetRequestedHighQ_recipA().has_value()); + autoHighQ->setToolTip("Integrate out to the highest Q the detector reaches. The high-Q value is not " + "used while this is on."); auto *spacing = new SliderPlusBox(1e-5, 1.0, 0.001, 5, page, SliderPlusBox::ScaleType::Logarithmic); spacing->setValue(azint_.GetQSpacing_recipA()); auto *azimBins = new QComboBox(page); @@ -402,6 +424,7 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { azimBins->addItem(QString::number(b), b); azimBins->setCurrentIndex(azimBins->findData(azint_.GetAzimuthalBinCount())); az->addRow("Low Q [Å⁻¹]", lowQ); + az->addRow("", autoHighQ); az->addRow("High Q [Å⁻¹]", highQ); az->addRow("Q spacing [Å⁻¹]", spacing); az->addRow("Azimuthal bins", azimBins); @@ -410,7 +433,9 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { layout->addWidget(azSection); auto emitAz = [=, this] { - azint_.QRange_recipA(static_cast(lowQ->value()), static_cast(highQ->value())); + azint_.QRange_recipA(static_cast(lowQ->value()), + autoHighQ->isChecked() ? std::nullopt + : std::optional(highQ->value())); azint_.QSpacing_recipA(static_cast(spacing->value())); azint_.AzimuthalBinCount(azimBins->currentData().toInt()); emit azintChanged(azint_); @@ -419,6 +444,11 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { connect(highQ, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); }); connect(spacing, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); }); connect(azimBins, &QComboBox::currentIndexChanged, this, [emitAz] { emitAz(); }); + connect(autoHighQ, &QCheckBox::toggled, this, [emitAz, highQ](bool on) { + highQ->setEnabled(!on); + emitAz(); + }); + highQ->setEnabled(!autoHighQ->isChecked()); // Powder calibration (calibrant rings + geometry refinement) - reuse the existing widget. auto *powderSection = new CollapsibleSection("Powder calibration", page); @@ -436,7 +466,16 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { return page; } +void JFJochViewerSettingsDock::SyncMinPix() { + // Per-image min-pix indexes each frame on its own, which only means something for stills; rotation + // indexing builds one lattice from all frames, so it keeps the fixed value. + spot_.min_pix_per_spot = (adaptive_min_pix_ && !indexing_.GetRotationIndexing()) + ? std::optional() + : std::optional(min_pix_value_); +} + void JFJochViewerSettingsDock::EmitSpotFinding() { + SyncMinPix(); emit spotFindingChanged(spot_, indexing_, max_spots_); } @@ -528,8 +567,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { auto *friedel = new QCheckBox("Merge Friedel pairs", this); friedel->setChecked(scaling_.GetMergeFriedel()); - auto *refineB = new QCheckBox("Refine B-factor", this); - refineB->setChecked(scaling_.GetRefineB()); auto *corrections = new QCheckBox("Correction surfaces (decay + absorption)", this); corrections->setChecked(scaling_.GetCorrectionSurfaces()); corrections->setToolTip("Rotation only: fit a radiation-damage decay and a goniometer-frame absorption " @@ -547,7 +584,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { highRes->setEnabled(limitRes->isChecked()); form->addRow("", friedel); - form->addRow("", refineB); form->addRow("", corrections); form->addRow("", partRefine); // Compact, and aligned with the checkboxes above: the limit checkbox + value sit together in the @@ -561,7 +597,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { auto emitScaling = [=, this] { scaling_.MergeFriedel(friedel->isChecked()); - scaling_.RefineB(refineB->isChecked()); scaling_.CorrectionSurfaces(corrections->isChecked()); scaling_.StillsPartialityRefine(partRefine->isChecked()); scaling_.HighResolutionLimit_A(limitRes->isChecked() @@ -569,7 +604,6 @@ QWidget *JFJochViewerSettingsDock::BuildScalingSection() { emit scalingChanged(scaling_); }; connect(friedel, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); - connect(refineB, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(partRefine, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(limitRes, &QCheckBox::toggled, this, [emitScaling, highRes](bool on) { diff --git a/viewer/widgets/JFJochViewerSettingsDock.h b/viewer/widgets/JFJochViewerSettingsDock.h index d4e36b2a..c95b67d1 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.h +++ b/viewer/widgets/JFJochViewerSettingsDock.h @@ -65,6 +65,11 @@ private: DiffractionExperiment experiment_; bool have_experiment_ = false; // geometry edits only take effect once a dataset is loaded int64_t max_spots_ = 1000; + // Min-pix is kept as the pair the UI shows - the fixed value AND whether to choose it per image - + // because spot_.min_pix_per_spot can only hold one of the two (unset = per image). SyncMinPix() + // turns the pair into that field, and per-image only ever applies to stills. + bool adaptive_min_pix_ = false; + int64_t min_pix_value_ = 2; bool azint_mode_ = false; // false = MX page, true = AzInt page (drives "Analyze dataset") // "Analyze dataset" hero button, disabled while a live HTTP source is connected. @@ -107,6 +112,7 @@ private: QWidget *BuildScalingSection(); QWidget *BuildReferenceSection(); QWidget *BuildAzIntPage(); + void SyncMinPix(); void EmitSpotFinding(); void EmitExperiment(); void ApplyProcessingMode(); // "Process as stills" -> indexing + scaling rotation/stills mode diff --git a/viewer/windows/JFJochViewerImageListWindow.cpp b/viewer/windows/JFJochViewerImageListWindow.cpp index abd2dc5c..c46ee440 100644 --- a/viewer/windows/JFJochViewerImageListWindow.cpp +++ b/viewer/windows/JFJochViewerImageListWindow.cpp @@ -51,7 +51,7 @@ JFJochViewerImageListWindow::JFJochViewerImageListWindow(QWidget *parent) : JFJo void JFJochViewerImageListWindow::setupTableModel() { - tableModel->setColumnCount(9); + tableModel->setColumnCount(8); tableModel->setHeaderData(0, Qt::Horizontal, "#"); tableModel->setHeaderData(1, Qt::Horizontal, "Bkg"); tableModel->setHeaderData(2, Qt::Horizontal, "Index"); @@ -60,7 +60,6 @@ void JFJochViewerImageListWindow::setupTableModel() tableModel->setHeaderData(5, Qt::Horizontal, "Max val"); tableModel->setHeaderData(6, Qt::Horizontal, "Scale factor"); tableModel->setHeaderData(7, Qt::Horizontal, "Scale CC"); -tableModel->setHeaderData(8, Qt::Horizontal, "Scale B [A^2]"); } void JFJochViewerImageListWindow::addDataRow(int imageNumber, double backgroundEstimate, @@ -69,8 +68,7 @@ void JFJochViewerImageListWindow::addDataRow(int imageNumber, double backgroundE double resolutionEstimate, int64_t max_value, double image_scale_factor, - double image_scale_cc_percent, - double image_scale_b) { + double image_scale_cc_percent) { QList rowItems; QStandardItem *imageItem = new QStandardItem(); @@ -128,16 +126,6 @@ void JFJochViewerImageListWindow::addDataRow(int imageNumber, double backgroundE } rowItems.append(scaleCCItem); - QStandardItem *scaleBItem = new QStandardItem(); - if (std::isfinite(image_scale_b)) { - scaleBItem->setData(QString::number(image_scale_b, 'f', 2), Qt::DisplayRole); - scaleBItem->setData(image_scale_b, ScaleSortRole); - } else { - scaleBItem->setData("N/A", Qt::DisplayRole); - scaleBItem->setData(ScaleNotAvailableSortValue, ScaleSortRole); - } - rowItems.append(scaleBItem); - tableModel->appendRow(rowItems); } @@ -184,10 +172,6 @@ void JFJochViewerImageListWindow::datasetLoaded(std::shared_ptrimage_scale_cc.size() > i) image_scale_cc_percent = dataset->image_scale_cc[i] * 100.0; - double image_scale_b = NAN; - if (dataset->image_scale_b.size() > i) - image_scale_b = dataset->image_scale_b[i]; - addDataRow(i + 1, bkg_estimate, indexing_result, @@ -195,8 +179,7 @@ void JFJochViewerImageListWindow::datasetLoaded(std::shared_ptrUnits("deg"); - data_file.SaveVector("/entry/MX/imageScaleBFactor", image_scale_b_factor.vec())->Units("Angstrom^2"); } } diff --git a/writer/HDF5DataFilePluginMX.h b/writer/HDF5DataFilePluginMX.h index 745c7580..3f0265b1 100644 --- a/writer/HDF5DataFilePluginMX.h +++ b/writer/HDF5DataFilePluginMX.h @@ -61,7 +61,6 @@ class HDF5DataFilePluginMX : public HDF5DataFilePlugin { AutoIncrVector image_scale_factor{NAN}; AutoIncrVector image_scale_cc{NAN}; AutoIncrVector image_scale_mosaicity{NAN}; - AutoIncrVector image_scale_b_factor{NAN}; public: explicit HDF5DataFilePluginMX(const StartMessage& msg); void OpenFile(HDF5File &data_file, const DataMessage& msg, size_t images_per_file) override; diff --git a/writer/HDF5NXmx.cpp b/writer/HDF5NXmx.cpp index c631f167..d0ff03e5 100644 --- a/writer/HDF5NXmx.cpp +++ b/writer/HDF5NXmx.cpp @@ -1037,7 +1037,6 @@ void NXmx::EndResultVectors(const EndMessage &end) { SaveVectorIfMissing(*hdf5_file, "/entry/MX/imageScaleFactor", end.image_scale_factor); SaveVectorIfMissing(*hdf5_file, "/entry/MX/imageScaleCC", end.image_scale_cc); SaveVectorIfMissing(*hdf5_file, "/entry/MX/imageScaleMosaicity", end.image_scale_mosaicity, "deg"); - SaveVectorIfMissing(*hdf5_file, "/entry/MX/imageScaleBFactor", end.image_scale_b_factor, "Angstrom^2"); if (!end.niggli_class.empty()) SaveVectorIfMissing(*hdf5_file, "/entry/MX/niggliClass", end.niggli_class); } -- 2.54.0 From c9b52857e075156a4ef8f4f2d3c9d88dbdc27526 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 10:46:52 +0200 Subject: [PATCH 039/295] rugnux: warn when --min-image-cc is ignored; drop a dead robust estimator --min-image-cc is consumed only by the stills merge (MergeOnTheFly); RotationScaleMerge never reads it. On rotation data it was accepted and then silently did nothing, so a run that looked filtered was not. It now says so. FitProfileRadius_MAD had zero callers - a robust twin sitting uncalled next to the non-robust estimator that is actually used is a trap, so it goes. Neither changes any result: verified on a rotation dataset (indexing rate, cell, space group and merge statistics identical, warning emitted). Context for anyone tempted to wire that estimator in: I tested exactly that today and it is NOT justified. The population it would clip is truncated by construction - a spot is only marked `indexed` when its fractional-Miller norm is inside the indexing tolerance - and is measurably shorter-tailed than Gaussian (kurtosis 2.85). Across four serial-stills datasets a MAD-clipped variant only narrowed the prediction window (-17% integrated reflections everywhere), which was neutral on strong data and destroyed real signal on weak data (one set lost completeness 96.0 -> 93.9%), with R-free 0.3753 -> 0.3767. Co-Authored-By: Claude Opus 5 (1M context) --- image_analysis/indexing/FitProfileRadius.cpp | 26 -------------------- image_analysis/indexing/FitProfileRadius.h | 2 -- rugnux/rugnux_cli.cpp | 4 +++ 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/image_analysis/indexing/FitProfileRadius.cpp b/image_analysis/indexing/FitProfileRadius.cpp index bd63a708..43ef5f2f 100644 --- a/image_analysis/indexing/FitProfileRadius.cpp +++ b/image_analysis/indexing/FitProfileRadius.cpp @@ -5,32 +5,6 @@ #include // std::nth_element #include // std::fabs -std::optional FitProfileRadius_MAD(const std::vector& xs) { - std::vector absx; - absx.reserve(xs.size()); - for (const auto &s: xs) { - if (s.indexed) - absx.push_back(std::fabs(s.dist_ewald_sphere)); - } - - if (absx.empty()) - return std::nullopt; - - std::nth_element(absx.begin(), absx.begin() + absx.size() / 2, absx.end()); - float med; - if (absx.size() % 2 == 1) { - med = absx[absx.size() / 2]; - } else { - auto it1 = absx.begin() + (absx.size() / 2 - 1); - auto it2 = absx.begin() + (absx.size() / 2); - float a = *it1; - float b = *it2; - med = 0.5f * (a + b); - } - // Normal consistency factor for MAD - return 1.4826f * med; -} - std::optional FitProfileRadius(const std::vector& spots, float bandwidth_sigma, float wavelength_A) { double sum_squares = 0.0; // measured excitation-error variance (sum dist_ewald^2) diff --git a/image_analysis/indexing/FitProfileRadius.h b/image_analysis/indexing/FitProfileRadius.h index c63f7288..803d04cc 100644 --- a/image_analysis/indexing/FitProfileRadius.h +++ b/image_analysis/indexing/FitProfileRadius.h @@ -8,8 +8,6 @@ #include "../../common/SpotToSave.h" -std::optional FitProfileRadius_MAD(const std::vector& spots); - // Intrinsic excitation-error (mosaicity+divergence) width from the indexed-spot spread. When a finite // energy bandwidth is given, its radial smear (bandwidth_sigma*lambda/(2 d^2)) is deconvolved out, so // the result is the intrinsic width and bandwidth is not double-counted by prediction (which re-adds diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index d1e1e66f..383625d9 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -1425,6 +1425,10 @@ int main(int argc, char **argv) { scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(rotation_indexing ? 1.0 : 0.0)); scaling_settings.ForcedMosaicity(forced_mosaicity_arg); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction + // The per-image CC filter is consumed by the stills merge (MergeOnTheFly) only - RotationScaleMerge + // never reads it - so on rotation data it would otherwise be accepted and silently do nothing. + if (min_image_cc > 0.0 && rotation_indexing) + logger.Warning("--min-image-cc is ignored for rotation data: it filters the stills merge only"); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or(rotation_indexing ? REJECT_OUTLIERS_DEFAULT_NSIGMA : 0.0)); -- 2.54.0 From 3171b071e6889ca2f52048d2fc6a476b679ff635 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 21:05:16 +0200 Subject: [PATCH 040/295] Space-group search: judge a promotion against its parent, not against the error model The point-group decision moved with the AMOUNT of data at fixed physics: a partially twinned trigonal crystal was promoted into the twin's holohedry whenever the search happened to see a larger first-pass merge, and kept its true subgroup when it saw a smaller one. Simulation over 6 noise draws with only the merge multiplicity varying: the twin is promoted 0/6 at multiplicity 2 and 6/6 at 18, while the genuine control is promoted 6/6 throughout. The cause is that every existing gate is a ratio to the merge error model - b_parent grows toward the true systematic scatter as sigma shrinks with 1/sqrt(N), while b_cand is already saturated by the twin's disagreement, so the ratio slides down through a fixed veto. The parent statistic moves with data amount and the candidate statistic does not. Gate promotions on the operator disagreement H = <|I1-I2|/(I1+I2)> instead, as the ratio of the operators a promotion ADDS to the parent group's own operators on the same reflections. There is no sigma in it, so it cannot drift with the error model, and the parent normalisation cancels data quality. Measured over 27 runs, 5 promotion types and 450-1800 images: genuine symmetry 0.862-1.219, merohedral twins 1.270-2.084. On the synthetic grid it is flat across a 9x change in multiplicity - genuine pinned at 1.00, twins 3-12x the bound - which is precisely the property the old gates lacked. chi^2 and the systematic-b stay as secondary vetoes; they protect against non-crystallographic pseudo-symmetry, which is where correlation-based scoring is weak. Pick the parent carefully: 422 has two maximal subgroups of order 4, and on a tetragonal crystal twinned by 2[100] the rival (222) is CC-confirmed too and CONTAINS the twin laws, so normalising against it hides the twin among the promotion's own real operators (ratio 8.19 against the true parent, 0.78 against the rival). Where several parents tie, judge on the most damning. Also: - Report a refused promotion instead of silently processing lower. Merging a twin in the twin's holohedry averages non-equivalent reflections into each other and cannot be undone from the output; keeping the subgroup costs only redundancy. The refusal names the group and the number that caused it. - Stop the twinning report from arguing in a circle. It ran after adoption and conditioned on the adopted group, so a promotion into a holohedral Laue class made it print "no merohedral twin law exists" - the test was conditioned on the decision it should audit. Twinning is now also measured on the subgroup merge before adoption, and the post-adoption text says when its own conclusion is not authoritative. - Compare PRIMITIVE cell volumes in the first-pass scheme tie-break. A centred setting's cell is an exact integer multiple of its primitive one (a rhombohedral lattice in hexagonal axes is exactly 3x), so the integer-supercell test fired on a pure setting difference and demoted a good scheme to a threefold-smaller merge - which is what let the twin see the small merge to begin with. Rotation battery, 33 crystals: point-group agreement 30/33 -> 29/33, one crystal moved. That crystal (P422 -> P222) is the one with the known unresolved integration defect where reflections near the rotation-axis plane are wildly mis-integrated; its symmetry mates genuinely disagree, and its lower-symmetry merge is measurably better (ISa 2.72 -> 3.63, high-shell CC 75.4 -> 86.0). The threshold was not moved to accommodate it: 1.25 sits inside the measured gap and widening it would admit real twins. Separately the tie-break improved one crystal's CC1/2 from 77.7 to 84.0. Tests: a synthetic twin-fraction x multiplicity grid, which is what the search had never had - the existing tests are noise-free and exercise only Stage B absences. A NOTE ON WHAT WAS TRIED AND REJECTED, so it is not rebuilt: the obvious "physics-anchored" statistic is the disattenuated cross-validated correlation rho = corr(I_half0(h), I_half1(Rh)) / corr(I_half0, I_half1), which is 1 for real symmetry at any data quality and 2a(1-a)/((1-a)^2+a^2) for a twin. It passes the synthetic grid perfectly and FAILS ON REAL DATA IN BOTH DIRECTIONS - five false refusals of genuine symmetry on the battery, and it waves through a twin (rho 0.998) that H refuses. The reason is that cc_half correlates the two halves of the SAME reflection and so measures only random error, while cc_cross compares DIFFERENT reflections carrying different systematic error; dividing by cc_half removes the noise and leaves a systematic floor that varies by crystal AND by operator. Genuine rho measures 0.9987 on strong data and 0.73 on weak. A synthetic generator validates a statistic's arithmetic, never its premise, and this premise - that the only departure from exact symmetry is noise - is false for every real crystal. Any per-operator agreement statistic needs a same-crystal reference; an absolute threshold on one cannot be made to work by tuning. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/SearchSpaceGroup.cpp | 104 ++++- image_analysis/scale_merge/SearchSpaceGroup.h | 39 ++ .../scale_merge/TwinningAnalysis.cpp | 6 + image_analysis/scale_merge/TwinningAnalysis.h | 5 + rugnux/Rugnux.cpp | 36 +- tests/CMakeLists.txt | 2 + tests/SearchSpaceGroupTwinTest.cpp | 361 ++++++++++++++++++ tests/SyntheticMergedReflections.h | 239 ++++++++++++ 8 files changed, 785 insertions(+), 7 deletions(-) create mode 100644 tests/SearchSpaceGroupTwinTest.cpp create mode 100644 tests/SyntheticMergedReflections.h diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index 4939e945..b0e64d2e 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,12 @@ namespace { return false; } + std::string FormatDouble(double v, int decimals) { + std::ostringstream o; + o << std::fixed << std::setprecision(decimals) << v; + return o.str(); + } + std::array RotKey(const gemmi::Op& op) { std::array out{}; for (int i = 0; i < 3; ++i) @@ -265,6 +272,17 @@ SearchSpaceGroupResult SearchSpaceGroup( s.op_triplet_hkl = op.as_hkl().triplet('h'); s.n_pairs = static_cast(x.size()); s.cc = PearsonCC(x, y); + // Sigma-free disagreement over the same pairs (see SpaceGroupOptions::max_operator_h_ratio). + double h_sum = 0.0; + int h_n = 0; + for (size_t p = 0; p < x.size(); ++p) { + const double denom = x[p] + y[p]; + if (denom > 0.0) { + h_sum += std::fabs(x[p] - y[p]) / denom; + ++h_n; + } + } + s.h_stat = h_n > 0 ? h_sum / h_n : 0.0; s.present = s.n_pairs >= opt.min_pairs_per_operator && std::isfinite(s.cc) && s.cc >= opt.min_operator_cc; return s; @@ -432,6 +450,8 @@ SearchSpaceGroupResult SearchSpaceGroup( // Operator-CC-confirmed candidates, each with its merge chi^2 and systematic-error b; chi2_ref = // the most consistent. struct PGCand { const PointGroupInfo* pg; int order; double min_class_cc; double chi2; double b_extra; }; + int refused_order = 0; + std::string refused_pg_hm, refused_why; std::vector pg_cands; double chi2_ref = std::numeric_limits::infinity(); for (const auto& pg : point_groups) { @@ -472,16 +492,59 @@ SearchSpaceGroupResult SearchSpaceGroup( // (imperfectly scaled data) and VETOES a twin whose chi^2 now looks self-consistent but whose b // balloons - the chi^2 ratio alone no longer separates them. double parent_b = -1.0; + const PointGroupInfo *parent_pg = nullptr; + // Every confirmed subgroup of the largest order below this candidate. There can be more than one + // - 422 has both 4 and 222 - and on a twinned crystal the rival is not a harmless alternative: a + // P4 crystal twinned by 2[100] has its two twin 2-folds confirmed, so 222 is CC-confirmed too and + // CONTAINS the twin laws. Normalising the H test against it hides the twin among the promotion's + // own real operators (measured on the synthetic grid: ratio 8.19 against the true parent 4, 0.78 + // against the rival 222). Which one is the true parent is exactly what is unknown here, so the + // promotion must answer to all of them. + std::vector parents; if (!c.pg->rotations.empty()) { int parent_order = 0; for (const auto& s : pg_cands) - if (s.order < c.order && s.order > parent_order + if (s.order < c.order && s.order >= parent_order && std::includes(c.pg->rotation_set.begin(), c.pg->rotation_set.end(), s.pg->rotation_set.begin(), s.pg->rotation_set.end())) { - parent_order = s.order; - parent_b = s.b_extra; + if (s.order > parent_order) { + parent_order = s.order; + parent_b = s.b_extra; + parent_pg = s.pg; + parents.clear(); + } + parents.push_back(s.pg); } } + + // Sigma-free twin test: compare the disagreement H of the operators this promotion ADDS with the + // disagreement of the parent's own operators, measured on the same reflections. A real operator + // relates equal intensities and matches the parent; a twin law relates different ones and reads + // systematically higher. Skipped when either side has too few pairs to mean anything, and when + // there is no parent group to normalise against (the first step out of P1). Where several parents + // tie (see above), the promotion is judged on the most damning of them. + double h_ratio = std::numeric_limits::quiet_NaN(); + for (const auto *parent : parents) { + double h_new = 0.0, h_par = 0.0; + int n_new = 0, n_par = 0, pairs_new = 0, pairs_par = 0; + for (const auto &rot : c.pg->rotations) { + if (rot.rot == gemmi::Op::identity().rot) + continue; + const auto &os = operator_score(rot); + if (os.n_pairs < opt.min_pairs_per_operator) + continue; + const bool in_parent = std::binary_search(parent->rotation_set.begin(), + parent->rotation_set.end(), RotKey(rot)); + if (in_parent) { h_par += os.h_stat; ++n_par; pairs_par += os.n_pairs; } + else { h_new += os.h_stat; ++n_new; pairs_new += os.n_pairs; } + } + if (n_new > 0 && n_par > 0 && pairs_new >= opt.min_pairs_for_h + && pairs_par >= opt.min_pairs_for_h && h_par > 0.0) { + const double r = (h_new / n_new) / (h_par / n_par); + if (!std::isfinite(h_ratio) || r > h_ratio) + h_ratio = r; + } + } // The chi^2 ratio is only trustworthy when the error model is calibrated. When even the best // subgroup's reduced chi^2 (chi2_ref) is far above 1 - weak, low-resolution data whose merged // sigmas are badly under-estimated - the ratio grows with point-group order for genuine high @@ -504,8 +567,29 @@ SearchSpaceGroupResult SearchSpaceGroup( && c.b_extra > std::max(parent_b, opt.min_systematic_b_for_veto) * opt.max_systematic_b_veto) consistent = false; - if (!consistent) + // The H test is a necessary condition for promotion where it can be computed: it is the only + // statistic measured to separate genuine symmetry from a merohedral twin across data amounts. + const bool h_refused = std::isfinite(h_ratio) && h_ratio > opt.max_operator_h_ratio; + if (h_refused) + consistent = false; + + if (!consistent) { + // Record the highest-order refusal so the caller can say WHY it is processing lower. + if (c.order > refused_order && c.pg->representative) { + refused_order = c.order; + refused_pg_hm = c.pg->representative->point_group_hm(); + if (h_refused) + refused_why = "operator disagreement H is " + FormatDouble(h_ratio, 2) + + "x the parent's (bound " + FormatDouble(opt.max_operator_h_ratio, 2) + + ") - the added operator relates unequal intensities, as a twin law does"; + else if (std::isfinite(c.chi2) && std::isfinite(chi2_ref)) + refused_why = "merge chi^2 is " + FormatDouble(c.chi2 / chi2_ref, 2) + + "x the subgroup's (bound " + FormatDouble(opt.max_merge_chi2_ratio, 2) + ")"; + else + refused_why = "the merge under it is not self-consistent"; + } continue; + } if (c.order > best_pg_order || (c.order == best_pg_order && c.min_class_cc > best_pg_min_cc)) { best_pg = c.pg; best_pg_order = c.order; @@ -524,6 +608,12 @@ SearchSpaceGroupResult SearchSpaceGroup( if (best_pg->representative) result.point_group_hm = best_pg->representative->point_group_hm(); + // Only report a refusal that is actually ABOVE what was adopted. + if (refused_order > best_pg_order) { + result.refused_point_group_hm = refused_pg_hm; + result.refused_reason = refused_why; + } + // --- Stage B: pick the space group within the point group --- // Without screw/centering determination, return the symmorphic representative. if (!opt.determine_space_group || best_pg->rotations.empty()) { @@ -674,6 +764,12 @@ std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result, size_t max_candidates_to_print) { std::ostringstream os; + if (!result.refused_point_group_hm.empty()) + os << "Higher symmetry " << result.refused_point_group_hm << " was confirmed by the operator " + "correlations but REFUSED: " << result.refused_reason << ".\n" + " Processing in the lower symmetry, which is the recoverable direction - if this is a " + "twin, merging in the higher group would average non-equivalent reflections together and " + "hide the twin law.\n"; os << "Point group: " << (result.point_group_hm.empty() ? "?" : result.point_group_hm) << " (from intensity correlations)\n"; diff --git a/image_analysis/scale_merge/SearchSpaceGroup.h b/image_analysis/scale_merge/SearchSpaceGroup.h index d8c35a9c..bafa48ca 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.h +++ b/image_analysis/scale_merge/SearchSpaceGroup.h @@ -30,6 +30,12 @@ struct SpaceGroupOperatorScore { double cc = 0.0; // correlation of I(h) with I(Rh) int n_pairs = 0; // independent reflection pairs the CC was computed from bool present = false; // operator confirmed as a real symmetry of the intensities + // Mean |I1-I2|/(I1+I2) over this operator's pairs - the disagreement the operator implies, with no + // sigma in it. Unlike chi^2 and the systematic-b, which are ratios to a merge error model that + // drifts with multiplicity, this is a property of the intensities alone; compared against an + // operator already confirmed on the same reflections it is what separates a real symmetry from a + // twin law (see max_operator_h_ratio). + double h_stat = 0.0; }; struct SpaceGroupCandidateScore { @@ -125,6 +131,30 @@ struct SearchSpaceGroupOptions { // is a non-negligible fraction of I; below the floor the increase is treated as noise, not a twin. double min_systematic_b_for_veto = 0.05; + // Promotion gate on the operator disagreement H = <|I1-I2|/(I1+I2)>, taken as the ratio of the + // operators the promotion ADDS to the operators of the parent group already confirmed on the same + // reflections. A real symmetry operator relates equal intensities, so its H matches the parent's + // (ratio ~1); a merohedral twin law relates DIFFERENT reflections mixed in proportion alpha, so its + // H is systematically larger. Measured over 27 runs spanning 5 promotion types and 450-1800 images: + // genuine symmetry 0.862-1.219, merohedral twins 1.270-2.084 - a clean gap, and unlike the chi^2 and + // systematic-b ratios (genuine 1.00-3.47 / 1.09-3.89 vs twin 1.35-3.32 / 1.77-4.76, fully + // interleaved) it does not drift with multiplicity, because there is no sigma in it and the parent + // normalisation cancels data quality. For a partial twin over the twin law is (1-2*alpha)/2, so + // the excess over the parent also estimates the twin fraction rather than being a tuned constant. + // + // The parent normalisation is what makes this work, and it is not optional. Symmetry-related + // reflections never agree exactly on real data - absorption, illumination and partiality differ + // between them - and that systematic floor varies by crystal AND by operator (a cubic 3-fold permutes + // axes, relating far-apart parts of reciprocal space, so it disagrees more than the 2-folds of its + // parent even when the symmetry is perfectly real). Measuring an added operator against the parent's + // own operators, on the same reflections, is what divides that floor out. An ABSOLUTE bound on any + // per-operator agreement statistic cannot: measured absolute values for genuine symmetry span the + // whole range from 0.99 on strong data down to 0.71 on weak, straddling every twin. + double max_operator_h_ratio = 1.25; + + // The H test needs at least this many pairs on both sides to mean anything. + int min_pairs_for_h = 200; + // Above this reduced chi^2 for the best subgroup (chi2_ref), the merged error model is treated as // badly miscalibrated (weak, low-resolution data whose sigmas are far too small): the fixed-sigma // chi^2 ratio then grows with point-group order for genuine high symmetry too and can no longer @@ -176,6 +206,15 @@ struct SearchSpaceGroupResult { std::string point_group_hm; // chosen point group, e.g. "422" std::vector operator_scores; // Stage A, all distinct operators tested std::vector candidates; // Stage B, ranked + + // A HIGHER point group whose operators the intensities confirmed (Stage A) but whose promotion the + // consistency tests refused, with the reason. Processing continues in the lower group, which is the + // safe direction: merging a twinned crystal in the twin's holohedry averages non-equivalent + // reflections into each other and is unrecoverable from the output (and makes the run report that no + // twin law exists), whereas keeping the subgroup costs only redundancy and can be promoted later. + // Empty when nothing was refused. Surfaced to the user - a silent demotion is how a twin gets missed. + std::string refused_point_group_hm; + std::string refused_reason; }; SearchSpaceGroupResult SearchSpaceGroup( diff --git a/image_analysis/scale_merge/TwinningAnalysis.cpp b/image_analysis/scale_merge/TwinningAnalysis.cpp index 92924b46..56e6fbc4 100644 --- a/image_analysis/scale_merge/TwinningAnalysis.cpp +++ b/image_analysis/scale_merge/TwinningAnalysis.cpp @@ -217,6 +217,12 @@ std::string TwinningAnalysisToText(const TwinningAnalysisResult& result) { os << " => Twinning suspected (estimated twin fraction ~" << result.estimated_twin_fraction << "). Statistics flag the presence of twinning, not\n" << " the twin law; confirm with a dedicated twin-law analysis.\n"; + else if (!result.merohedral_twinning_possible && result.laue_class_was_chosen_by_promotion) + os << " => Cannot rule out twinning from these numbers: the Laue class is holohedral, so no\n" + << " merohedral twin law exists WITHIN it - but this Laue class was chosen by the\n" + << " space-group search itself, and promoting into a twin's holohedry is precisely what\n" + << " a merohedral twin looks like. Judge the twinning from the subgroup statistics\n" + << " reported by the search, not from these.\n"; else if (!result.merohedral_twinning_possible) os << " => No twinning: the Laue class is holohedral, so no merohedral twin law exists\n" << " (any <|L|> below 0.5 here is a statistical artefact, not twinning).\n"; diff --git a/image_analysis/scale_merge/TwinningAnalysis.h b/image_analysis/scale_merge/TwinningAnalysis.h index 16714749..957b8efa 100644 --- a/image_analysis/scale_merge/TwinningAnalysis.h +++ b/image_analysis/scale_merge/TwinningAnalysis.h @@ -37,6 +37,11 @@ struct TwinningAnalysisResult { // False when the Laue class is holohedral (4/mmm, 6/mmm, m-3m, rhombohedral -3m): no merohedral // twin law can exist, so a low <|L|> / second moment there is a statistical artefact, not twinning. bool merohedral_twinning_possible = true; + + // Set when this analysis ran on a merge whose space group the pipeline CHOSE by promoting past a + // subgroup. The "holohedral Laue class, so no twin law exists" conclusion is then circular - the + // promotion is exactly what a twin would have caused - so the report must not state it as a fact. + bool laue_class_was_chosen_by_promotion = false; }; // The space group (when known) is used to drop centric reflections, which follow different diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index f51c5e14..71a9a68b 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -561,7 +561,14 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b if (!result.has_value()) continue; const int score = count_indexed(idx, *result); - const double vol = std::abs(result->lattice.CalcVolume()); + // Compare PRIMITIVE volumes: two schemes can find the same lattice in different + // settings, and a centred setting's cell is an exact integer multiple of its primitive + // one - a rhombohedral lattice in hexagonal axes is exactly 3x its primitive + // rhombohedral cell. Comparing the centred volumes makes the supercell test below fire + // on that pair and "demote" a perfectly good setting to a threefold-smaller merge, which + // is enough to change the space group the search then picks. + const double vol = std::abs( + result->lattice.ToPrimitive(result->search_result.centering).CalcVolume()); const std::string &name = schemes[i].first; logger.Info("First-pass scheme '{}': indexes {}/{} validation frames", name, score, static_cast(validation.size())); @@ -575,8 +582,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // whichever ran first. When the two schemes tie on frames but their cell volumes are // related by an integer factor >=2, the larger cell is that spurious supercell and the // smaller is the true reduced cell: take it, regardless of scheme order. A near-integer - // ratio separates a real axis multiplication from a centering coincidence (a rhombohedral - // H cell vs its C2 sub-cell is 1.5x, non-integer, and is correctly left alone). + // ratio separates a real axis multiplication from a centering coincidence. Volumes are + // primitive (see above), so a pure setting difference is a ratio of 1 and never fires. bool integer_subcell = false; if (bp.result.has_value() && !clearly_more && vol > 1.0 && bp.vol > 1.0) { const bool tied = static_cast(score) >= bp.score * 0.9f - 0.5f; @@ -1128,6 +1135,10 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // test): its conventional setting does not match the indexer's primitive frame, so the two-pass must // NOT reuse it - the second pass re-searches (and re-runs the same test) instead. bool sg_reindexed = false; + // True when the point group below was chosen BY THE SEARCH (rather than given by the user) and is + // above triclinic - i.e. a promotion was made, which is what makes a later "the Laue class is + // holohedral so there is no twin law" conclusion circular. + bool promoted_point_group = false; if (!experiment_.GetGemmiSpaceGroup().has_value()) { SearchSpaceGroupOptions sg_opts; sg_opts.merge_friedel = experiment_.GetScalingSettings().GetMergeFriedel(); @@ -1139,6 +1150,22 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b sg_opts.lattice_system = end_msg.rotation_lattice_type->crystal_system; auto sg_search = SearchSpaceGroup(sm.merged, sg_opts); + // Twinning evidence measured BEFORE any promotion, on the P1/subgroup merge the search was + // given. Reported alongside the post-adoption analysis: once a higher Laue class has been + // adopted, its own twinning test can only say "no twin law exists within this class", which + // is circular - the promotion is what a twin would have caused. These numbers are not. + const auto pre_promotion_twinning = AnalyzeTwinning(sm.merged, nullptr); + if (pre_promotion_twinning.twinning_suspected) + logger.Warning("Twinning indicators BEFORE the space-group decision (subgroup merge): " + "<|L|> = {:.3f}, second moment = {:.3f} (untwinned 0.500 / 2.00) - if the " + "search promoted the point group, treat the promotion with suspicion", + pre_promotion_twinning.mean_abs_l, pre_promotion_twinning.second_moment); + promoted_point_group = !sg_search.point_group_hm.empty() && sg_search.point_group_hm != "1"; + if (!sg_search.refused_point_group_hm.empty()) + logger.Warning("Higher symmetry {} was confirmed by the operator correlations but refused: " + "{}. Processing in the lower symmetry (the recoverable direction).", + sg_search.refused_point_group_hm, sg_search.refused_reason); + // Miller-index reindex under a change of basis: (h,k,l)_conv = reindex * (h,k,l)_prim. const auto reindex_hkl = [](auto &r, const gemmi::Mat33 &m) { const double h = r.h, k = r.k, l = r.l; @@ -1338,6 +1365,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b const gemmi::SpaceGroup *twin_sg = twin_sg_number ? gemmi::find_spacegroup_by_number(twin_sg_number.value()) : nullptr; result.twinning = AnalyzeTwinning(sm.merged, twin_sg); + // Mark the conclusion as non-authoritative when the Laue class was reached by a promotion the + // search itself made, so the text cannot claim "no twin law exists" on its own say-so. + result.twinning.laue_class_was_chosen_by_promotion = promoted_point_group; stats_text << TwinningAnalysisToText(result.twinning) << "\n"; // Indexing-ambiguity (alternative-indexing) advisory. When the lattice metric symmetry exceeds diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9145a49b..12fc070d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,8 @@ ADD_EXECUTABLE(jfjoch_test HKLKeyTest.cpp TCPImagePusherTest.cpp SearchSpaceGroupTest.cpp + SearchSpaceGroupTwinTest.cpp + SyntheticMergedReflections.h XDSPluginTest.cpp MergeScaleTest.cpp RfreeFlagsTest.cpp diff --git a/tests/SearchSpaceGroupTwinTest.cpp b/tests/SearchSpaceGroupTwinTest.cpp new file mode 100644 index 00000000..43f7402b --- /dev/null +++ b/tests/SearchSpaceGroupTwinTest.cpp @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include "../image_analysis/scale_merge/SearchSpaceGroup.h" +#include "SyntheticMergedReflections.h" +#include "gemmi/symmetry.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Point-group decision on MEROHEDRALLY TWINNED data. +// +// SearchSpaceGroupTest.cpp exercises Stage B (systematic absences) on noise-free, exactly-symmetric +// intensities. The decision that actually goes wrong on real crystals is Stage A: whether the extra +// operator of the metric holohedry is a real symmetry or a twin law. That decision runs through the +// merge chi^2 gate, its systematic-b rescue and the systematic-b veto - none of which the noise-free +// set can reach, because it has no errors for a chi^2 to be reduced by. +// +// Three crystals are modelled per lattice, all with the SAME metric symmetry (a merohedral twin has +// the supergroup's metric, so the lattice cannot arbitrate - only the intensities can): +// * genuine supergroup - structure factors invariant under the supergroup; must be promoted; +// * untwinned subgroup - structure factors invariant under the subgroup only; must NOT be; +// * twinned subgroup - the same crystal at twin fraction alpha; must NOT be promoted for any +// 0 <= alpha < 0.5, because promoting averages the two twin domains into +// one intensity and the twin is then unrecoverable downstream. +// At alpha = 0.5 the twin is physically indistinguishable from real symmetry, so only "terminates +// and returns one of the two" is asserted. +// +// Each is measured through two merge-quality regimes and five merge multiplicities. Neither changes +// any physics - they change only how well the same crystal was measured and how honest its sigmas +// are - so no decision above may move with them. That is the property the harness exists to pin +// down; every case below asserts it outright. + +namespace { + using jfjoch_test::SyntheticMergeParams; + + struct TwinCrystal { + std::string name; + std::string sub; // the crystal's true space group when twinned + std::string super; // supergroup of index 2; its extra operator is the twin law + gemmi::CrystalSystem system; // metric (lattice) symmetry, as rugnux passes it from indexing + // The OTHER maximal subgroup of the supergroup of the same order as `sub`, when one exists. + // 422 has two - 4 and 222 - and only one of them is the crystal. Which one a parent-normalised + // statistic divides by decides the promotion, so the harness reports both; empty when the + // supergroup has only one maximal subgroup of that order (32 over 3) and the choice cannot arise. + std::string rival_parent; + }; + + const std::vector crystals = { + {"trigonal 3 -> 32 (R3 / R32, twin law k,h,-l)", "R 3 :H", "R 32 :H", + gemmi::CrystalSystem::Trigonal, ""}, + {"tetragonal 4 -> 422 (P4 / P422, twin law h,-k,-l)", "P 4", "P 4 2 2", + gemmi::CrystalSystem::Tetragonal, "P 2 2 2"}, + }; + + // How honest the merged sigmas are. Both regimes are ways a fitted error model misses in + // practice, and neither is a property of the crystal's symmetry. + struct MergeQuality { + std::string name; + double sigma_miscalibration; + double error_model_b; + std::optional true_systematic_b; + }; + + const std::vector merge_quality = { + // The usual case: merged sigmas come out ~1.7x too small across the board, with the error + // model's b matching the systematic scatter that is actually there. + {"sigmas 1.7x too small", 1.7, 0.05, std::nullopt}, + // The other way a fitted error model misses: the statistical sigmas come out somewhat too + // LARGE while b - the asymptotic I/sigma ceiling, ISa = 1/b - is fitted 3x too optimistic, so + // the systematic scatter present is 3x what the merged sigmas' floor admits. + {"ISa 3x too optimistic", 0.6, 0.02, 0.06}, + }; + + const std::vector multiplicities = {2, 3, 6, 9, 18}; + const std::vector twin_fractions = {0.0, 0.05, 0.10, 0.20, 0.35, 0.50}; + + struct Decision { + std::string point_group; + std::string space_group; + size_t n_merged = 0; + std::vector operators; + std::string report; + }; + + // true_group is the symmetry the structure factors have: the subgroup for the twin series, the + // supergroup for the genuine-high-symmetry control (where the twin law is a real symmetry + // operator, so the twin fraction has no effect). + Decision Decide(const TwinCrystal& c, const MergeQuality& q, + const std::string& true_group, double alpha, int multiplicity) { + SyntheticMergeParams p; + p.true_space_group = true_group; + p.twin_supergroup = c.super; + p.twin_fraction = alpha; + p.multiplicity = multiplicity; + p.sigma_miscalibration = q.sigma_miscalibration; + p.error_model_b = q.error_model_b; + p.true_systematic_b = q.true_systematic_b; + // One fixed seed for the whole harness: every case draws the same unit-normal stream, so two + // cases differ only in the knob under test and every test is reproducible. + p.seed = 20260727; + + const auto merged = jfjoch_test::GenerateSyntheticMerged(p); + REQUIRE(merged.size() > 5000); // a realistic dataset, not a handful of reflections + + SearchSpaceGroupOptions opt; // as rugnux/Rugnux.cpp sets it + opt.merge_friedel = true; + opt.lattice_system = c.system; + + const auto result = SearchSpaceGroup(merged, opt); + + Decision d; + d.point_group = result.point_group_hm; + d.space_group = result.best_space_group.has_value() + ? result.best_space_group->short_name() : "none"; + d.n_merged = merged.size(); + d.operators = result.operator_scores; + d.report = SearchSpaceGroupResultToText(result); + return d; + } + + std::string PointGroupOf(const std::string& space_group_name) { + return gemmi::get_spacegroup_by_name(space_group_name).point_group_hm(); + } + + std::string ShortNameOf(const std::string& space_group_name) { + return gemmi::get_spacegroup_by_name(space_group_name).short_name(); + } + + // The hkl triplets SearchSpaceGroup labels a space group's own rotations with. Lets a test tell the + // crystal's real symmetry operators from the twin law among result.operator_scores. + std::set OperatorTripletsOf(const std::string& space_group_name) { + std::set out; + const auto& sg = gemmi::get_spacegroup_by_name(space_group_name); + for (const auto& op : sg.operations().derive_symmorphic().sym_ops) { + if (op.rot == gemmi::Op::identity().rot) + continue; + out.insert(gemmi::Op{op.rot, {0, 0, 0}, op.notation}.as_hkl().triplet('h')); + } + return out; + } + + // The statistic the promotion is actually decided on: the mean operator disagreement + // H = <|I1-I2|/(I1+I2)> over the operators the promotion ADDS, divided by the mean over the parent + // group's own operators, measured on the same reflections. Mirrors what SearchSpaceGroup computes + // for the sub -> super step, so a test can report the margin the max_operator_h_ratio bound has. + // + // The parent normalisation is the whole design, not a detail. An ABSOLUTE per-operator bound cannot + // work: two reflections related by a real symmetry operator still disagree, because they carry + // DIFFERENT systematic error - absorption, illumination, partiality - and how much of that a + // crystal has is a property of the measurement, not of its symmetry. So a genuine operator's own + // disagreement ranges over whatever the data quality happens to be, and any fixed bound placed on + // it rejects good crystals at one end or waves twins through at the other. Dividing by the parent + // operators - already confirmed, measured on the same reflections, carrying the same systematic + // floor - cancels the data quality and leaves only the question being asked: does the ADDED + // operator relate intensities as equal as the parent's do (real symmetry), or systematically less + // equal (a twin law mixing non-equivalent reflections)? + double HRatioOfPromotion(const std::vector& operators, + const std::string& parent_group) { + const auto parent_ops = OperatorTripletsOf(parent_group); + double h_added = 0.0, h_parent = 0.0; + int n_added = 0, n_parent = 0; + for (const auto& s : operators) { + if (s.n_pairs < 200) // SearchSpaceGroupOptions::min_pairs_for_h + continue; + if (parent_ops.count(s.op_triplet_hkl) > 0) { h_parent += s.h_stat; ++n_parent; } + else { h_added += s.h_stat; ++n_added; } + } + if (n_added == 0 || n_parent == 0 || h_parent <= 0.0) + return std::numeric_limits::quiet_NaN(); + return (h_added / n_added) / (h_parent / n_parent); + } + + std::string Describe(const TwinCrystal& c, const MergeQuality& q, double alpha, int multiplicity) { + std::ostringstream os; + os << c.name << ", " << q.name << ", twin fraction " << std::fixed << std::setprecision(2) + << alpha << ", multiplicity " << multiplicity; + return os.str(); + } +} + +// Positive control: a crystal whose structure factors really do have the higher symmetry must be +// promoted to it. Guards the twin tests below against a criterion that simply never promotes. +TEST_CASE("SearchSpaceGroup promotes a genuinely high-symmetry crystal", + "[SearchSpaceGroup][twin]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) + for (int mult : multiplicities) { + DYNAMIC_SECTION(c.name << ", " << q.name << ", genuine supergroup, multiplicity " << mult) { + const auto d = Decide(c, q, c.super, 0.0, mult); + INFO(d.report); + CHECK(d.point_group == PointGroupOf(c.super)); + CHECK(d.space_group == ShortNameOf(c.super)); + } + } +} + +// Negative control: an UNTWINNED crystal of the true subgroup (alpha = 0) must not be promoted - its +// extra metric operator relates reflections that are simply not equivalent. +TEST_CASE("SearchSpaceGroup keeps an untwinned low-symmetry crystal in its subgroup", + "[SearchSpaceGroup][twin]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) + for (int mult : multiplicities) { + DYNAMIC_SECTION(Describe(c, q, 0.0, mult)) { + const auto d = Decide(c, q, c.sub, 0.0, mult); + INFO(d.report); + CHECK(d.point_group == PointGroupOf(c.sub)); + CHECK(d.space_group == ShortNameOf(c.sub)); + } + } +} + +// A partial merohedral twin must stay in its true subgroup. Promoting it averages the two twin +// domains into one intensity, which no later step can undo: the twin fraction is not recoverable and +// the merged data are simply wrong. +TEST_CASE("SearchSpaceGroup keeps a partially twinned crystal in its true subgroup", + "[SearchSpaceGroup][twin]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) + for (double alpha : {0.05, 0.10, 0.20, 0.35}) + for (int mult : multiplicities) { + DYNAMIC_SECTION(Describe(c, q, alpha, mult)) { + const auto d = Decide(c, q, c.sub, alpha, mult); + INFO(d.report); + CHECK(d.point_group == PointGroupOf(c.sub)); + CHECK(d.space_group == ShortNameOf(c.sub)); + } + } +} + +// A PERFECT (alpha = 0.5) merohedral twin produces intensities that are exactly invariant under the +// twin law: I_obs(h) = I_obs(twin h) for every reflection. No intensity statistic can tell it from a +// crystal that genuinely has the higher symmetry - the information is not in the data (it takes a +// different measurement, e.g. the |E| distribution's second moment, to even suspect it). So the only +// thing asserted here is that the search terminates and returns one of the two. +TEST_CASE("SearchSpaceGroup on a perfect merohedral twin returns one of the two symmetries", + "[SearchSpaceGroup][twin]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) + for (int mult : multiplicities) { + DYNAMIC_SECTION(Describe(c, q, 0.5, mult)) { + const auto d = Decide(c, q, c.sub, 0.5, mult); + INFO(d.report); + REQUIRE(d.space_group != "none"); + CHECK((d.point_group == PointGroupOf(c.sub) || + d.point_group == PointGroupOf(c.super))); + } + } +} + +// THE property this harness exists for. Multiplicity changes only the sigmas - the random part of a +// merged sigma averages down as 1/sqrt(n) while the systematic floor b*|I| does not - so it changes +// how well the SAME crystal is measured, never what its symmetry is. A symmetry decision that moves +// when the same crystal is merged 2x instead of 18x is a defect of the criterion, not a property of +// the data. +// +// This is what a criterion thresholded on merge chi^2 or on a systematic-b RATIO cannot deliver: both +// are ratios to an error model that multiplicity and the sigma calibration move, so the tetragonal +// 4 -> 422 twin at alpha 0.20 / 0.35 used to flip - promoted at multiplicity 2 and kept at 18 with +// under-calibrated sigmas, and the other way round with an over-optimistic ISa. The operator +// disagreement ratio H_added/H_parent holds instead because there is no sigma in it at all: it +// compares intensities with intensities, and normalising against the parent group's own operators on +// the same reflections divides out both the data quality and the systematic floor that multiplicity +// and the error model move. An absolute bound on a single operator's H would not survive this - see +// HRatioOfPromotion. +TEST_CASE("SearchSpaceGroup point-group decision does not depend on merge multiplicity", + "[SearchSpaceGroup][twin]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) + for (double alpha : twin_fractions) { + DYNAMIC_SECTION(Describe(c, q, alpha, 2) + " vs multiplicity 18") { + const auto low = Decide(c, q, c.sub, alpha, 2); + const auto high = Decide(c, q, c.sub, alpha, 18); + INFO("multiplicity 2:\n" << low.report << "\nmultiplicity 18:\n" << high.report); + CHECK(low.point_group == high.point_group); + CHECK(low.space_group == high.space_group); + } + } +} + +// Diagnostic, not run by default: ./jfjoch_test "[twin-h]" +// Prints the operator-disagreement ratio H_added/H_parent that the promotion is decided on, for the +// genuine high-symmetry crystal and for each twin fraction, across both merge-quality regimes and +// every multiplicity - i.e. how much margin the max_operator_h_ratio bound actually has, and whether +// either side of it drifts with data quality or data amount. +TEST_CASE("SearchSpaceGroup operator H ratio margins", "[.][twin-h]") { + SearchSpaceGroupOptions defaults; + std::cout << "H_added / H_parent for the sub -> super promotion; bound " + << defaults.max_operator_h_ratio << " (above = refused as a twin)\n"; + for (const auto& c : crystals) + for (const auto& q : merge_quality) + // Both normalisations where the supergroup has two maximal subgroups of the same order: + // against the crystal's true parent, and against its rival. + for (const auto& parent : c.rival_parent.empty() + ? std::vector{c.sub} + : std::vector{c.sub, c.rival_parent}) { + std::cout << "\n" << c.name << "\n merge quality: " << q.name + << " normalised against " << ShortNameOf(parent) + << (parent == c.sub ? " (the crystal's own parent)" : " (the RIVAL parent)") + << "\n"; + std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << ("mult " + std::to_string(mult)); + std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << std::fixed << std::setprecision(3) + << HRatioOfPromotion(Decide(c, q, c.super, 0.0, mult).operators, parent); + std::cout << "\n"; + for (double alpha : twin_fractions) { + std::ostringstream label; + label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha; + std::cout << " " << std::setw(22) << std::left << label.str() << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << std::fixed << std::setprecision(3) + << HRatioOfPromotion(Decide(c, q, c.sub, alpha, mult).operators, parent); + std::cout << "\n"; + } + } + SUCCEED(); +} + +// Diagnostic, not run by default (hidden by the [.] tag): +// ./jfjoch_test "[twin-table]" +// prints the decision for every (true symmetry, twin fraction, multiplicity) combination in both +// merge-quality regimes - the table a redesign of the point-group criterion should be judged against. +TEST_CASE("SearchSpaceGroup twin decision table", "[.][twin-table]") { + for (const auto& c : crystals) + for (const auto& q : merge_quality) { + const auto reference = Decide(c, q, c.super, 0.0, 6); + std::cout << "\n" << c.name << "\n merge quality: " << q.name + << " (true subgroup " << ShortNameOf(c.sub) + << ", supergroup " << ShortNameOf(c.super) << ", " + << reference.n_merged << " merged reflections)\n"; + std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << ("mult " + std::to_string(mult)); + std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << Decide(c, q, c.super, 0.0, mult).space_group; + std::cout << "\n"; + for (double alpha : twin_fractions) { + std::ostringstream label; + label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha; + std::cout << " " << std::setw(22) << std::left << label.str() << std::right; + for (int mult : multiplicities) + std::cout << std::setw(12) << Decide(c, q, c.sub, alpha, mult).space_group; + std::cout << "\n"; + } + } + SUCCEED(); +} diff --git a/tests/SyntheticMergedReflections.h b/tests/SyntheticMergedReflections.h new file mode 100644 index 00000000..cc34e58d --- /dev/null +++ b/tests/SyntheticMergedReflections.h @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../common/Reflection.h" +#include "gemmi/symmetry.hpp" +#include "gemmi/unitcell.hpp" + +// Synthetic merged intensities for the space-group / point-group search tests. +// +// The set produced here is what SearchSpaceGroup is fed in production: a P1 merge (one entry per +// Friedel-canonical hkl) with intensities, sigmas and half-set intensities. Unlike the noise-free +// set in SearchSpaceGroupTest.cpp it models the three things the point-group decision actually +// depends on: +// +// * a merohedral TWIN - the crystal's true point group is a subgroup of index 2 of the metric +// (lattice) point group, and the twin law is the operator that separates them: +// I_obs(h) = (1-alpha) I_true(h) + alpha I_true(twin h) +// I_true is a function of the TRUE (sub)group's asu, so the subgroup symmetry is exact and only +// the extra supergroup operator is broken - by (1-2 alpha) (I_true(h) - I_true(twin h)). At +// alpha = 0.5 the two are identical and the twin is indistinguishable from real symmetry. +// Setting the true group to the SUPERgroup instead gives the untwinned high-symmetry control. +// +// * the MERGE MULTIPLICITY, modelled the way the real merge behaves (Merge.h, +// SigmaWithSystematicFloor): the random part of the merged sigma averages down as +// 1/sqrt(multiplicity) while the systematic part (b*I - absorption, partiality, beam flicker; +// correlated across a reflection's repeats) does not, so the merged sigma is +// max(sigma_statistical, b*|I|). Multiplicity therefore changes the sigmas but NOT the physics, +// and no symmetry decision may depend on it. +// +// * an ERROR-MODEL MISCALIBRATION - real merged sigmas come out under-estimated (~1.7x), which is +// what pushes the merge's reduced chi^2 to ~3 and switches SearchSpaceGroup between its +// chi^2-ratio and systematic-b regimes. +// +// Intensities follow a Wilson (exponential) distribution with a resolution fall-off, so they span a +// realistic dynamic range; the "structure factor" is a hash of the asu index, not a draw from the +// RNG stream, so the same crystal is reproduced bit-for-bit whatever the multiplicity or the twin +// fraction and two runs differ only in the knob under test. + +namespace jfjoch_test { + + struct SyntheticMergeParams { + // Symmetry the structure factors actually have: the generated intensities are exactly + // invariant under it, whatever the twin fraction. + std::string true_space_group = "R 3 :H"; + + // Supergroup of index 2 over true_space_group; its extra operator is the twin law. Set it + // equal to true_space_group to model a crystal that GENUINELY has the higher symmetry - + // there is then no extra operator, twinning by a real symmetry operator is a no-op, and the + // twin fraction has no effect. + std::string twin_supergroup = "R 32 :H"; + + // Merohedral twin fraction alpha in [0, 0.5]. 0 = untwinned, 0.5 = perfect twin (whose + // intensities are exactly invariant under the twin law, hence indistinguishable from a + // crystal that really has the supergroup symmetry). + double twin_fraction = 0.0; + + // Number of observations merged into each reflection; at least 2, so both half-sets exist. + int multiplicity = 6; + + // Error model. error_model_b is the b the merge FITTED and floors its merged sigmas with + // (ISa = 1/b); true_systematic_b is the intensity-proportional systematic scatter actually + // present in the data - unset means the two agree, i.e. a perfectly fitted error model. + // sigma_miscalibration is how many times too SMALL the merged sigmas come out overall + // (> 1 = under-estimated, the usual case; < 1 = over-estimated). + double error_model_b = 0.05; + std::optional true_systematic_b; + double sigma_miscalibration = 1.7; + + // Intensity distribution: mean intensity at infinite resolution, Wilson B fall-off, and the + // background variance that keeps sigma finite for near-zero (systematically absent) + // reflections. + double mean_intensity = 8000.0; + double wilson_b_A2 = 25.0; + double background_variance = 400.0; + + double d_min_A = 2.5; + + uint32_t seed = 20260727; + }; + + namespace detail { + inline uint64_t Mix64(uint64_t x) { + x ^= x >> 33; x *= 0xff51afd7ed558ccdULL; + x ^= x >> 33; x *= 0xc4ceb9fe1a85ec53ULL; + x ^= x >> 33; return x; + } + + // Uniform in (0, 1), a pure function of the Miller index. + inline double UniformFromHkl(const gemmi::Op::Miller& hkl) { + const uint64_t x = Mix64(static_cast(hkl[0] + 512) * 0x9e3779b97f4a7c15ULL ^ + Mix64(static_cast(hkl[1] + 512)) ^ + (Mix64(static_cast(hkl[2] + 512)) << 1)); + return (static_cast(x >> 11) + 0.5) * (1.0 / 9007199254740992.0); + } + + inline std::vector> RotationSetOf(const gemmi::SpaceGroup& sg) { + std::vector> out; + for (const auto& op : sg.operations().derive_symmorphic().sym_ops) { + std::array rot{}; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + rot[i * 3 + j] = op.rot[i][j]; + out.push_back(rot); + } + std::sort(out.begin(), out.end()); + return out; + } + } + + // The twin law: a rotation of the supergroup that is not in the subgroup. Any of them gives the + // same twinned intensities (an index-2 subgroup is normal, so the coset members differ by a + // subgroup operator, which leaves I_true unchanged), so the first one found is used. + inline gemmi::Op TwinLaw(const gemmi::SpaceGroup& sub, const gemmi::SpaceGroup& super) { + const auto sub_rots = detail::RotationSetOf(sub); + for (const auto& op : super.operations().derive_symmorphic().sym_ops) { + std::array rot{}; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + rot[i * 3 + j] = op.rot[i][j]; + if (!std::binary_search(sub_rots.begin(), sub_rots.end(), rot)) + return gemmi::Op{op.rot, {0, 0, 0}, op.notation}; + } + return gemmi::Op::identity(); + } + + // A cell consistent with the space group's crystal system. Synthetic throughout - the tests must + // not carry the cell of any real sample. + inline gemmi::UnitCell SyntheticCellFor(const gemmi::SpaceGroup& sg) { + switch (sg.crystal_system()) { + case gemmi::CrystalSystem::Triclinic: return {33, 37, 41, 85, 95, 105}; + case gemmi::CrystalSystem::Monoclinic: return {37, 43, 51, 90, 101, 90}; + case gemmi::CrystalSystem::Orthorhombic: return {37, 43, 51, 90, 90, 90}; + case gemmi::CrystalSystem::Tetragonal: return {47, 47, 63, 90, 90, 90}; + case gemmi::CrystalSystem::Trigonal: + case gemmi::CrystalSystem::Hexagonal: return {51, 51, 71, 90, 90, 120}; + case gemmi::CrystalSystem::Cubic: return {57, 57, 57, 90, 90, 90}; + } + return {50, 50, 50, 90, 90, 90}; + } + + inline std::vector GenerateSyntheticMerged(const SyntheticMergeParams& p) { + const gemmi::SpaceGroup& sub = gemmi::get_spacegroup_by_name(p.true_space_group); + const gemmi::SpaceGroup& super = gemmi::get_spacegroup_by_name(p.twin_supergroup); + const gemmi::Op twin = TwinLaw(sub, super); + const gemmi::UnitCell cell = SyntheticCellFor(sub); + const gemmi::GroupOps gops = sub.operations(); + const gemmi::ReciprocalAsu rasu(&sub); + + // True (untwinned) intensity: Wilson-distributed |F|^2 of the subgroup asu, with a + // resolution fall-off. Systematically absent reflections (here: the lattice centering) carry + // no intensity - they are what Stage B confirms the centering from. + auto true_intensity = [&](const gemmi::Op::Miller& hkl) -> double { + if (gops.is_systematically_absent(hkl)) + return 0.0; + const auto asu = rasu.to_asu_sign(hkl, gops).first; + const double e_squared = -std::log(detail::UniformFromHkl(asu)); // mean 1, exponential + const double d = cell.calculate_d(hkl); + return p.mean_intensity * e_squared * std::exp(-p.wilson_b_A2 / (2.0 * d * d)); + }; + + // Half-set split of the multiplicity (n0 >= n1); both halves see the same systematic error. + const int n_obs = std::max(2, p.multiplicity); + const int n_half[2] = {(n_obs + 1) / 2, n_obs / 2}; + + std::mt19937 rng(p.seed); + std::normal_distribution gauss(0.0, 1.0); + + const int hmax = static_cast(std::ceil(cell.a / p.d_min_A)) + 1; + const int kmax = static_cast(std::ceil(cell.b / p.d_min_A)) + 1; + const int lmax = static_cast(std::ceil(cell.c / p.d_min_A)) + 1; + + std::vector merged; + + for (int h = -hmax; h <= hmax; ++h) + for (int k = -kmax; k <= kmax; ++k) + for (int l = -lmax; l <= lmax; ++l) { + // One entry per Friedel pair, matching the Friedel-merged P1 set the search gets. + if (std::make_tuple(h, k, l) <= std::make_tuple(-h, -k, -l)) + continue; + const gemmi::Op::Miller hkl{{h, k, l}}; + const double d = cell.calculate_d(hkl); + if (!(d >= p.d_min_A)) + continue; + + const auto twinned = twin.apply_to_hkl(hkl); + const double i_obs = (1.0 - p.twin_fraction) * true_intensity(hkl) + + p.twin_fraction * true_intensity(twinned); + + // Statistical error of one observation, and of the merge of n of them. + const double sigma_one = std::sqrt(i_obs + p.background_variance); + // Systematic error: a property of the reflection, identical in every observation + // of it, so it survives the merge - this is what the b*|I| sigma floor models. + const double systematic = + p.true_systematic_b.value_or(p.error_model_b) * i_obs * gauss(rng); + + MergedReflection r; + r.h = h; + r.k = k; + r.l = l; + r.d = static_cast(d); + + double sum_n_i = 0.0; + for (int half = 0; half < 2; ++half) { + const double sigma_stat_half = + sigma_one / std::sqrt(static_cast(n_half[half])); + const double i_half = i_obs + systematic + sigma_stat_half * gauss(rng); + r.I_half[half] = static_cast(i_half); + r.sigma_half[half] = static_cast( + std::max(sigma_stat_half, p.error_model_b * std::abs(i_half)) / + p.sigma_miscalibration); + sum_n_i += n_half[half] * i_half; + } + + const double i_merged = sum_n_i / n_obs; + const double sigma_stat = sigma_one / std::sqrt(static_cast(n_obs)); + r.I = static_cast(i_merged); + // Merge.h SigmaWithSystematicFloor, then thrown off by the error-model + // miscalibration. + r.sigma = static_cast( + std::max(sigma_stat, p.error_model_b * std::abs(i_merged)) / p.sigma_miscalibration); + + merged.push_back(r); + } + + return merged; + } +} -- 2.54.0 From 0cd8cb7ba354654efe3cff4101df3d335d665785 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 21:38:07 +0200 Subject: [PATCH 041/295] Space-group search: name the veto that actually refused a promotion The refusal message fell through to the chi^2 branch whenever the systematic-b balloon veto was the binding test, so it reported a chi^2 ratio that did not justify the refusal at all - on one battery crystal it printed "merge chi^2 is 1.25x the subgroup's (bound 1.85)", i.e. a number comfortably inside its own bound, as the reason for processing in the lower symmetry. A diagnostic that names the wrong cause is worse than none: it sends the reader after the wrong statistic. Report the b test when it is what fired, with both b values and the bound. Co-Authored-By: Claude Opus 5 (1M context) --- image_analysis/scale_merge/SearchSpaceGroup.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index b0e64d2e..c381f5f1 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -563,9 +563,12 @@ SearchSpaceGroupResult SearchSpaceGroup( // The parent b is floored (min_systematic_b_for_veto) so a near-zero parent on excellent data cannot // fabricate a huge ratio out of a still-tiny absolute b (a genuine 422 at b=0.05 over a 222 parent at // b=0.008 is not a twin - a real twin drives b to ~0.19 regardless). + bool b_refused = false; if (consistent && parent_b > 1e-4 - && c.b_extra > std::max(parent_b, opt.min_systematic_b_for_veto) * opt.max_systematic_b_veto) + && c.b_extra > std::max(parent_b, opt.min_systematic_b_for_veto) * opt.max_systematic_b_veto) { consistent = false; + b_refused = true; + } // The H test is a necessary condition for promotion where it can be computed: it is the only // statistic measured to separate genuine symmetry from a merohedral twin across data amounts. @@ -582,6 +585,11 @@ SearchSpaceGroupResult SearchSpaceGroup( refused_why = "operator disagreement H is " + FormatDouble(h_ratio, 2) + "x the parent's (bound " + FormatDouble(opt.max_operator_h_ratio, 2) + ") - the added operator relates unequal intensities, as a twin law does"; + else if (b_refused) + refused_why = "its merge's systematic error b is " + FormatDouble(c.b_extra, 3) + + " against the subgroup's " + FormatDouble(parent_b, 3) + + " (bound " + FormatDouble(opt.max_systematic_b_veto, 2) + + "x) - the added operator forces unequal intensities together"; else if (std::isfinite(c.chi2) && std::isfinite(chi2_ref)) refused_why = "merge chi^2 is " + FormatDouble(c.chi2 / chi2_ref, 2) + "x the subgroup's (bound " + FormatDouble(opt.max_merge_chi2_ratio, 2) + ")"; -- 2.54.0 From 7040987125f18d906f08017db4624b97c40fa2a7 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 22:43:58 +0200 Subject: [PATCH 042/295] Rotation scaling: do not trust a per-frame scale that has collapsed toward zero The per-frame scale enters every intensity as 1/G, and SolveScaleIRLS floors G at zero and nothing else. A frame whose fit is not determined by its data can return G ~ 0.002 against a run median of 0.865, and every observation it carries is then multiplied by ~500 - sigma by the identical factor, which is why no sigma-based outlier test can see it and why this looked for a long time like a partiality problem. (The 1/partiality path is in fact guarded: min_captured_fraction floors it at 0.7 by default on rotation.) The window smoothing that should have absorbed such a frame instead made it permanent. It averages log G over a window, so a scale collapsing toward zero does not merely corrupt its own frame - its logarithm drags the whole window down. Worse, where a run has a stretch of frames too sparse to fit at all, the only FITTED frames in a window can be the collapsed ones, and the geometric mean then averages the fault with itself. Measured on a multi-lattice dataset: frames 816 and 818 fitted G = 0.0023 and 0.0014 with every neighbour from 800 to 839 unfitted, so smoothing set G = 0.0018 across the whole neighbourhood - a 546x amplification. About 500 observations of 152000 (0.66%) then carried 99% of sum(I^2), and the merged CC1/2 read 17.2% where the same data with the classic finder read 93.7%. Treat a fitted scale far below the run's median as what it is - an undetermined scale, exactly like the too-few-reflections case the code already handles - rather than as a successful fit. Such frames no longer contribute to the smoothing mean, and a frame whose own scale is not credible takes the neighbourhood's, or the run's typical scale when the neighbourhood holds nothing credible either. The bound is a RATIO to the run's own median because the rotation per-frame G is not gauge-fixed: G and the group means have an exact global multiplicative degeneracy, and the fitted median drifts over 0.745-1.358 across the battery. An absolute floor would reject everything in a run that drifted low. MIN_CREDIBLE_SCALE_RATIO = 0.02 was chosen from measurement over 12 crystals in the default configuration, where the smallest legitimate min(G)/median(G) is 0.070; the failing case sat at 0.0017. It is 3.5x below anything real and 12x above the failure. Effect on the intensity tail of the failing case: max I 10224 -> 438, and the top 1000 observations' share of sum(I^2) 0.990 -> 0.421 (the classic-finder reference is 0.632, so the tail is now cleaner than the run this was compared against). Rotation battery, 33 crystals in the default configuration: ZERO crystals differ - no space group, CC1/2, high-shell CC or ISa change anywhere. The guard fires only on the pathology. It does NOT rescue that dataset: with the amplification gone its CC1/2 is 26.2% and R_meas 49.2% against the classic finder's 93.7% and 27.8%. Adaptive detection degrades those intensities for a second, independent reason that is still open. This commit removes a latent hazard for any run with a sparse stretch of frames; it is not the fix for that dataset. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.cpp | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index 290bef99..c08cbf3b 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -31,6 +31,17 @@ namespace { // implementation is numerically identical - see the comments there for the details. constexpr size_t MIN_REFLECTIONS = 20; // per-frame scale needs at least this many constexpr double SCALE_ROBUST_K = 3.0; // Cauchy loss scale (sigma units) for the per-frame G fit + + // A fitted per-frame scale below this fraction of the run's median is not a measurement of anything: + // it would mean the frame received 2% of its neighbours' dose while still producing indexable spots. + // It matters because the scale enters as 1/G, so a G that collapses toward zero multiplies every + // intensity on that frame without bound - and sigma by the identical factor, which is why no + // sigma-based outlier test can see it. Measured over 12 rotation crystals in the default + // configuration, the smallest LEGITIMATE min(G)/median(G) is 0.070; the case this bound exists for + // sat at 0.0017 (a 546x amplification that put 99% of sum(I^2) into 0.66% of the observations and + // took the merged CC1/2 from 93.7% to 17.2%). 0.02 is 3.5x below anything real and 12x above the + // failure. + constexpr double MIN_CREDIBLE_SCALE_RATIO = 0.02; constexpr float MAX_FRAME_GAP = 2.0f; // a rocking event is a run of frames no more apart than this constexpr double CHI2_1_MEDIAN = 0.454936; // A post-scale-fulls correction surface (decay / absorption) is applied only if its held-out @@ -1034,16 +1045,41 @@ void RotationScaleMerge::ComputeSmoothGWindow(const std::vector &g, int const int n = static_cast(g.size()); const int half = window / 2; g_smooth.assign(n, NAN); + + // The run's typical scale, and the floor below which a fitted scale is not credible + // (MIN_CREDIBLE_SCALE_RATIO). The window average is a GEOMETRIC mean, so a scale collapsing toward + // zero does not just corrupt its own frame - its logarithm drags the whole window down. Where a run + // has a stretch of frames too sparse to fit, the only fitted frames in a window can BE the collapsed + // ones, and the mean then averages the fault with itself and makes it permanent. + std::vector fitted; + fitted.reserve(n); + for (int j = 0; j < n; ++j) + if (frame_scaled_scratch[j] && std::isfinite(g[j]) && g[j] > 0.0) + fitted.push_back(g[j]); + double g_typ = NAN; + if (!fitted.empty()) { + const size_t mid = fitted.size() / 2; + std::nth_element(fitted.begin(), fitted.begin() + mid, fitted.end()); + g_typ = fitted[mid]; + } + const double g_floor = std::isfinite(g_typ) ? g_typ * MIN_CREDIBLE_SCALE_RATIO : 0.0; + for (int o = 0; o < n; ++o) { double sum_log = 0.0; int count = 0; for (int j = std::max(0, o - half); j <= std::min(n - 1, o + half); ++j) { - if (frame_scaled_scratch[j] && std::isfinite(g[j]) && g[j] > 0.0) { + if (frame_scaled_scratch[j] && std::isfinite(g[j]) && g[j] >= g_floor && g[j] > 0.0) { sum_log += std::log(g[j]); ++count; } } if (count > 0) g_smooth[o] = std::exp(sum_log / count); + // A frame whose own scale is not credible takes the neighbourhood's, or - when the neighbourhood + // holds nothing credible either - the run's typical scale. Callers divide g by g_smooth, so this + // replaces the collapsed scale rather than layering another correction on top of it. + if (frame_scaled_scratch[o] && std::isfinite(g[o]) && g[o] > 0.0 && g[o] < g_floor + && !std::isfinite(g_smooth[o])) + g_smooth[o] = g_typ; } } -- 2.54.0 From ec7a8261365448f428828e68b76b537e0729f1a0 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 27 Jul 2026 23:08:50 +0200 Subject: [PATCH 043/295] Rotation scaling: guard the fulls refit against a collapsed per-frame scale too 704098712 guarded the per-frame scale on the partials, but it put the check inside ComputeSmoothGWindow - which only the partials path calls. Step 4 refits the scale from scratch on the COMBINED FULLS (Unity model, so corr is exactly 1/G), with no smoothing and no floor, and that refit was still free to collapse toward zero. It is the same failure and it is worse here, because there is no window average to dilute it: the collapsed frame's own fulls are multiplied directly. Measured on a dataset where the previous commit had already fixed the partials stage, the fulls refit put 1/G = 559x and 175x on two frames carrying 517 observations, and the merged CC1/2 read 26.2% where the intensities ENTERING that stage were fine - better, in fact, than the comparison run's in all ten resolution shells (R_meas 34.5% vs 39.5%, CC1/2 89.1% vs 83.0%). Reject a collapsed scale here as well, on the same measured criterion, and let those fulls merge unscaled - the state the combine left them in, and the same fallback the fit already uses for a frame with too few reflections. The check reads the host fulls after both the CPU loop and the GPU ScaleFulls, so one implementation covers both paths; the corrected corr is pushed back to the device exactly as the correction surfaces already do. THIS IS NOT A DETECTION-MODE PROBLEM. Over 8 configurations (both spot finders x 4 frame ranges) the separation is exact: every run with a collapsed scale had CC1/2 <= 58.5%, every run without had CC1/2 >= 74.1%, and nothing else predicted it. On one frame range it is the DEFAULT finder that collapses (CC1/2 58.5%) while the other is clean at 93.8%. The instability was never specific to the finder; it was latent in the scaling stage and either finder could trip it. Same 8 configurations, with this commit: finder A full 93.7 -> 93.7 (untouched) finder A -s 1 58.5 -> 91.3 (recovered) finder A -e 899 94.0 -> 94.0 (untouched) finder A -e 898 87.2 -> 87.2 (untouched) finder B full 26.2 -> 91.1 (recovered) finder B -s 1 93.8 -> 93.8 (untouched) finder B -e 899 8.1 -> 91.7 (recovered) finder B -e 898 74.1 -> 74.1 (untouched) Every collapse recovers; every healthy run is unchanged. The CC1/2 spread over the four frame ranges falls from 35.5 to 6.8 points for one finder and from 85.7 to 19.7 for the other - this dataset was not sampling a deep instability when its CC1/2 swung between 17 and 94 across frame ranges, it was sampling whether this bug happened to fire. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.cpp | 40 ++++++++++++++++++- .../scale_merge/RotationScaleMerge.h | 8 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index c08cbf3b..b37f3237 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -1099,6 +1099,43 @@ void RotationScaleMerge::SmoothG(std::vector &obs, std::vector &g, g[f] = g_smooth[f]; } +bool RotationScaleMerge::RejectCollapsedFullScales() { + // The Unity model leaves corr = 1/G, and every full of a frame carries that frame's G. + std::vector g_frame(n_frames, NAN); + for (const auto &o : fulls) + if (o.frame >= 0 && o.frame < n_frames && std::isfinite(o.corr) && o.corr > 0.0f) + g_frame[o.frame] = 1.0 / static_cast(o.corr); + + std::vector fitted; + fitted.reserve(g_frame.size()); + for (const double gf : g_frame) + if (std::isfinite(gf) && gf > 0.0) + fitted.push_back(gf); + if (fitted.empty()) + return false; + const size_t mid = fitted.size() / 2; + std::nth_element(fitted.begin(), fitted.begin() + mid, fitted.end()); + const double g_floor = fitted[mid] * MIN_CREDIBLE_SCALE_RATIO; + + int n_rejected = 0; + double worst = 1.0; + for (auto &o : fulls) { + if (o.frame < 0 || o.frame >= n_frames) + continue; + const double gf = g_frame[o.frame]; + if (!std::isfinite(gf) || gf >= g_floor) + continue; + worst = std::max(worst, 1.0 / gf); + o.corr = 1.0f; // as if the frame's scale had never been fitted + ++n_rejected; + } + if (n_rejected > 0) + logger.Warning("Rejected the fitted scale of {} full(s) whose frame scaled to less than {:.0f}x " + "below the run median (worst {:.0f}x amplification); those frames merge unscaled", + n_rejected, 1.0 / MIN_CREDIBLE_SCALE_RATIO, worst / fitted[mid]); + return n_rejected > 0; +} + void RotationScaleMerge::Combine() { fulls.clear(); g_full.assign(n_frames, 1.0); @@ -2028,6 +2065,7 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, } logger.Info("Scaled fulls (XDS order, Unity model)"); } + const bool rejected_full_scales = scale_fulls && RejectCollapsedFullScales(); // --- 4b. Optional correction surfaces (decay = resolution x time; absorption = goniometer-frame // diffracted-beam direction), each an alternating multiplicative fit of the fulls' corr against @@ -2051,7 +2089,7 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, #ifdef JFJOCH_USE_CUDA // The corrections mutate the host fulls' corr; when the merge runs on the resident (GPU) fulls, push // the corrected corr back to the device so the merge reads it. - if (corrections && combined_on_gpu && scaled_fulls_on_gpu) { + if ((corrections || rejected_full_scales) && combined_on_gpu && scaled_fulls_on_gpu) { std::vector fcorr(fulls.size()); for (size_t i = 0; i < fulls.size(); ++i) fcorr[i] = fulls[i].corr; gpu_->SetFullsCorr(fcorr.data()); diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index 1ca5c5a5..2dd3be38 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -198,6 +198,14 @@ private: void Combine(); // partials -> fulls (CPU) + // Undo the scale on fulls whose frame's scale collapsed toward zero. The fulls are scaled with the + // Unity model, so their corr IS 1/G and a collapsed G multiplies every intensity on that frame + // without bound. This stage refits G from scratch with no smoothing to fall back on, so a rejected + // frame simply keeps the unscaled corr the combine gave it. Reads the host fulls, so it covers the + // CPU and GPU scaling paths alike. Returns true if anything was rejected (the caller then has to + // push the corrected corr back to the device). + bool RejectCollapsedFullScales(); + // Post-scale-fulls correction surfaces, each an alternating multiplicative fit of the host fulls' corr // against the merged reference (cheap host loops; the corrected corr is re-uploaded to the resident // fulls afterwards). Each is cross-validated (fit even frames, keep only if held-out odd equivalents -- 2.54.0 From ae126c3d5b02ec3cbe3fa76daa700a51f0b871a1 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 09:53:13 +0200 Subject: [PATCH 044/295] Per-image refinement: weight each spot by how strong it is for its resolution `RefineGeometryIfNeeded` hands XtalOptimizer the WHOLE spot list, not the indexed subset, and the first pass admits anything within 0.3 fractional-Miller units of an integer - which is 11.3% of RANDOMLY placed spots, since the admitted volume is (4/3)*pi*t^3. Every one of them then enters an unweighted L2 fit with an arbitrary rounded index. On images with many detections the refined orientation ends up 2.3-2.8 degrees from the goniometer-consistent one and explains 14 of its own 250 spots where the undragged orientation explains 68; mosaicity and profile radius inherit the error and integration follows. Weight every spot by its intensity divided by the median intensity of its own equal-count resolution shell, applied as w^2 on the squared residual with w^2 = r/(1+r). The shell normalisation is the point: refinement needs the high-resolution spots because they carry the cell and distance, and those are LEGITIMATELY weaker, so a raw intensity weight would suppress exactly the spots the fit depends on. Measured, the weight is resolution-neutral - median exactly 0.707 in every shell, and corr(w, 1/d^2) = -0.20 / -0.11 against -0.32 / -0.34 for the same function of un-normalised intensity. This is a PRIOR: it is computed from the spot alone and never looks at the current residual, so unlike a robust loss it cannot mistake a genuine spot for an outlier while the starting geometry is still far off and leave the fit unable to move. That failure is not hypothetical - a CauchyLoss on this same residual, at the scale the multi-frame GeometryRefiner uses, collapsed one crystal's indexing rate from 99.89% to 19.83% and was rejected. It does not work by telling good spots from bad, and it does not need to. No per-spot property separates spots that index from spots that do not: measured AUC is 0.53 for peak pixel, 0.53 for total intensity, 0.51 for pixel count, 0.45 for peakedness, and a logistic regression on all twelve available features with pairwise interactions reaches only 0.64. What the weight does is halve the EFFECTIVE COUNT of every spot (mean w^2 = 0.517), and the damage scales with the absolute count of unexplained spots in the objective - 80.6 per frame here against 36.8 for the finder that was never damaged. That is also why an empirical `--max-spots 66` cap works while leaving the list no purer than before: it reaches the same operating point by discarding spots. This reaches it without discarding any, and without a tuned constant. Rotation battery, 33 crystals, both spot finders: finder A 29/33 -> 30/33 point groups (one crystal P222 -> P4212 = XDS, its high-shell CC1/2 86.0 -> 98.4) finder B 28/33 -> 29/33 point groups (one crystal I222 -> I23, its high-shell CC1/2 14.8 -> 38.0) No crystal lost its point group in either mode and no run failed. On the meta-stable multi-lattice dataset the CC1/2 spread over four frame ranges falls 19.7 -> 13.1 for finder B, and the indexing rate rises in 8 of 8 configurations. The crystal that the rejected robust loss destroyed keeps its 99.89% indexing rate exactly. The cost, stated plainly: ISa falls by 0.2-1.7 on about five crystals (and rises on two). Point-group correctness is worth more than that - merging in the wrong symmetry cannot be undone from the output, whereas ISa is a quality metric of data that remain correct - but it is a real trade and not a free win. Off by default. The indexers pass a spot list they have already selected, so their calls are unchanged; only the per-image refinement, which gets the raw list, turns it on. Co-Authored-By: Claude Opus 5 (1M context) --- image_analysis/IndexAndRefine.cpp | 4 ++ .../geom_refinement/XtalOptimizer.cpp | 64 ++++++++++++++++++- .../geom_refinement/XtalOptimizer.h | 5 ++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/image_analysis/IndexAndRefine.cpp b/image_analysis/IndexAndRefine.cpp index 3e5e6885..a90a7819 100644 --- a/image_analysis/IndexAndRefine.cpp +++ b/image_analysis/IndexAndRefine.cpp @@ -254,6 +254,10 @@ void IndexAndRefine::RefineGeometryIfNeeded(DataMessage &msg, IndexAndRefine::In .refine_distance_mm = false, .refine_detector_angles = false, .refine_unit_cell = !experiment.IsRotationIndexing(), + // The whole spot list is passed below, not the indexed subset, so on weak images most of what + // enters the fit at the loose first tolerance is arbitrarily indexed noise. Weight every spot by + // how strong it is for its resolution so those contribute without dragging the orientation. + .weight_spots_by_confidence = true, .max_time = 0.04 // 40 ms is max allowed time for the operation }; diff --git a/image_analysis/geom_refinement/XtalOptimizer.cpp b/image_analysis/geom_refinement/XtalOptimizer.cpp index 45774851..e35d4e8e 100644 --- a/image_analysis/geom_refinement/XtalOptimizer.cpp +++ b/image_analysis/geom_refinement/XtalOptimizer.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-only #include "../../common/JFJochMath.h" +#include +#include #include #include "XtalOptimizer.h" @@ -81,6 +83,45 @@ struct RotationNormRegularizer { const double weight; }; +// Prior confidence weight per spot: how strong the spot is FOR ITS RESOLUTION. The frame's spots are +// ordered by resolution and cut into equal-count shells, and each intensity is divided by its shell +// median. Refinement needs the high-resolution spots (they carry the cell and distance information) and +// those are legitimately weaker, so a raw intensity weight would suppress exactly the wrong ones; the +// shell normalisation makes the weight resolution-neutral by construction. +// +// The weight enters as w^2 on the squared residual, w^2 = r/(1+r): the shell median contributes half, +// a 4x-median spot 0.8, a quarter-median spot 0.2. Weak spots still pull, they just do not drive. Unlike +// a robust loss this is a PRIOR - it never looks at the current residual, so it cannot mistake a genuine +// spot for an outlier when the starting geometry is far off and leave the fit unable to move. +static std::vector SpotConfidenceWeights(const std::vector &spots) { + constexpr size_t spots_per_shell = 32; + + std::vector by_res(spots.size()); + std::iota(by_res.begin(), by_res.end(), 0); + std::ranges::sort(by_res, {}, [&](size_t i) { return spots[i].d_A; }); + + const size_t nshells = std::max(1, spots.size() / spots_per_shell); + std::vector weight(spots.size()); + std::vector shell_intensity; + + for (size_t s = 0; s < nshells; s++) { + const size_t begin = s * spots.size() / nshells; + const size_t end = (s + 1) * spots.size() / nshells; + + shell_intensity.clear(); + for (size_t i = begin; i < end; i++) + shell_intensity.push_back(spots[by_res[i]].intensity); + std::ranges::nth_element(shell_intensity, shell_intensity.begin() + shell_intensity.size() / 2); + const double median = std::max(1e-3f, shell_intensity[shell_intensity.size() / 2]); + + for (size_t i = begin; i < end; i++) { + const double r = std::max(0.0f, spots[by_res[i]].intensity) / median; + weight[by_res[i]] = std::sqrt(r / (1.0 + r)); + } + } + return weight; +} + bool XtalOptimizerInternal(XtalOptimizerData &data, const std::vector> &spots, const float tolerance, @@ -144,10 +185,19 @@ bool XtalOptimizerInternal(XtalOptimizerData &data, const float tolerance_sq = tolerance * tolerance; + // Sum of w^2 over the spots that entered - the beam prior below is scaled by it so that its + // strength relative to the data is the same weighted or not. Equals the residual block count + // when the spots are unweighted. + double effective_spots = 0.0; + for (int i = 0; i < spots.size(); i++) { if (spots[i].empty()) continue; + std::vector weight; // empty = unweighted + if (data.weight_spots_by_confidence) + weight = SpotConfidenceWeights(spots[i]); + double angle_rad = 0.0; std::optional rot_matr; @@ -158,7 +208,8 @@ bool XtalOptimizerInternal(XtalOptimizerData &data, } // Add residuals for each point - for (const auto &pt: spots[i]) { + for (size_t j = 0; j < spots[i].size(); j++) { + const auto &pt = spots[i][j]; if (!data.index_ice_rings && pt.ice_ring) continue; @@ -180,6 +231,9 @@ bool XtalOptimizerInternal(XtalOptimizerData &data, if (norm_sq > tolerance_sq) continue; + const double weight_sq = weight.empty() ? 1.0 : weight[j] * weight[j]; + effective_spots += weight_sq; + problem.AddResidualBlock( new ceres::AutoDiffCostFunction( new XtalResidual(pt.x, pt.y, @@ -189,7 +243,11 @@ bool XtalOptimizerInternal(XtalOptimizerData &data, angle_rad, h, k, l, data.crystal_system)), - nullptr, + // Ceres has no per-residual weight; ScaledLoss(nullptr, a) multiplies the squared + // residual by the constant a, i.e. it applies a weight of sqrt(a) to the residual. + weight.empty() + ? nullptr + : new ceres::ScaledLoss(nullptr, weight_sq, ceres::TAKE_OWNERSHIP), beam, &distance_mm, detector_rot, @@ -231,7 +289,7 @@ bool XtalOptimizerInternal(XtalOptimizerData &data, // perpendicular direction, the prior wins the gauge one. constexpr double sigma_px = 3.0; const double k = data.geom.GetPixelSize_mm() / (distance_mm * data.geom.GetWavelength_A()); - const double w = k * std::sqrt(static_cast(problem.NumResidualBlocks())) / sigma_px; + const double w = k * std::sqrt(effective_spots) / sigma_px; problem.AddResidualBlock( new ceres::AutoDiffCostFunction( new BeamComponentPrior(parallel, beam[parallel], w)), diff --git a/image_analysis/geom_refinement/XtalOptimizer.h b/image_analysis/geom_refinement/XtalOptimizer.h index d28a884a..105a2faf 100644 --- a/image_analysis/geom_refinement/XtalOptimizer.h +++ b/image_analysis/geom_refinement/XtalOptimizer.h @@ -30,6 +30,11 @@ struct XtalOptimizerData { bool index_ice_rings = true; + // Weight each spot by how strong it is for its resolution, so that low-confidence spots contribute + // without driving the fit (see SpotConfidenceWeights). Off by default: the indexers call this with a + // spot list they have already selected, it is the per-image refinement that gets the raw list. + bool weight_spots_by_confidence = false; + float max_time = 1.0; std::optional axis; -- 2.54.0 From 11c7cab2e52edd10d77669caed547ea6fc6bc298 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 11:07:06 +0200 Subject: [PATCH 045/295] Space-group search: take the operator disagreement as a median, not a mean A merohedral twin mixes EVERY reflection with its twin mate, so it shifts the whole distribution of |I1-I2|/(I1+I2). A minority of badly measured reflections shifts only the tail. The mean cannot tell those apart; the median is blind to the second and just as sensitive to the first. Measured on real crystals, moving the statistic from the mean to the median leaves genuine promotions where they are and pushes every twin up: genuine tetragonal 1.016 -> 1.013 genuine lysozyme 1.051 -> 1.067 genuine tetragonal 1.238 -> 1.231 twin (-e 1050) 1.272 -> 1.447 twin (-e 450) 1.280 -> 1.622 twin (full) 1.441 -> 1.522 twin (-e 600) 1.427 -> 2.010 The margin around the 1.25 bound widens from 2.7% (genuine 1.238 against twin 1.272 - uncomfortably tight for a decision that cannot be undone downstream) to 17.5% (1.231 against 1.447). The bound itself does not move. Rotation battery, 33 crystals in both detection modes: no point group changed in either (30/33 and 29/33, as before), and only one crystal's numbers move at all - the one already documented as nondeterministic between repeat runs of the same binary. The synthetic twin-fraction x multiplicity grid passes unchanged. So this buys margin, not outcomes. Found while testing a different hypothesis, which the same measurement refuted: a tetragonal crystal whose 422 promotion is wrongly refused reads 1.484 by the mean and 1.472 by the median, i.e. its disagreement is distribution-wide and is NOT a badly-integrated minority. That crystal's cause is elsewhere and is not addressed here - see the note below. Its indexing-ambiguity operator (-k,-h,-l) lies INSIDE 422 but OUTSIDE 222, so the subgroup merge the search is given mixes lattices indexed in the two alternative hands. That corrupts exactly the 4-fold relationships and leaves the 2-fold ones intact - measured, the 222 step reads 0.917 and the 422 step 1.484 - and the corruption is indistinguishable from a twin law. Forcing the tetragonal group merges the two hands as equivalent and the same data give CC1/2 99.2% at multiplicity 10.7, matching XDS. The failure is worse the BETTER the frames index (99.9% vs 63.3% for the run that gets it right), because indexing more frames picks up more of both hands. So no statistic computed on a subgroup merge can arbitrate a promotion whose added operators include an indexing-ambiguity operator. Fixing that means resolving the ambiguity before the search, or detecting the coincidence and deciding another way; the operators needed to detect it are already computed (the run warns about them). Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/SearchSpaceGroup.cpp | 18 ++++++++------- image_analysis/scale_merge/SearchSpaceGroup.h | 23 +++++++++++-------- tests/SearchSpaceGroupTwinTest.cpp | 7 +++--- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index c381f5f1..9d108de5 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -273,16 +273,18 @@ SearchSpaceGroupResult SearchSpaceGroup( s.n_pairs = static_cast(x.size()); s.cc = PearsonCC(x, y); // Sigma-free disagreement over the same pairs (see SpaceGroupOptions::max_operator_h_ratio). - double h_sum = 0.0; - int h_n = 0; + std::vector hv; + hv.reserve(x.size()); for (size_t p = 0; p < x.size(); ++p) { const double denom = x[p] + y[p]; - if (denom > 0.0) { - h_sum += std::fabs(x[p] - y[p]) / denom; - ++h_n; - } + if (denom > 0.0) + hv.push_back(std::fabs(x[p] - y[p]) / denom); + } + if (!hv.empty()) { + const size_t mid = hv.size() / 2; + std::nth_element(hv.begin(), hv.begin() + mid, hv.end()); + s.h_stat = hv[mid]; } - s.h_stat = h_n > 0 ? h_sum / h_n : 0.0; s.present = s.n_pairs >= opt.min_pairs_per_operator && std::isfinite(s.cc) && s.cc >= opt.min_operator_cc; return s; @@ -536,7 +538,7 @@ SearchSpaceGroupResult SearchSpaceGroup( const bool in_parent = std::binary_search(parent->rotation_set.begin(), parent->rotation_set.end(), RotKey(rot)); if (in_parent) { h_par += os.h_stat; ++n_par; pairs_par += os.n_pairs; } - else { h_new += os.h_stat; ++n_new; pairs_new += os.n_pairs; } + else { h_new += os.h_stat; ++n_new; pairs_new += os.n_pairs; } } if (n_new > 0 && n_par > 0 && pairs_new >= opt.min_pairs_for_h && pairs_par >= opt.min_pairs_for_h && h_par > 0.0) { diff --git a/image_analysis/scale_merge/SearchSpaceGroup.h b/image_analysis/scale_merge/SearchSpaceGroup.h index bafa48ca..ad630672 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.h +++ b/image_analysis/scale_merge/SearchSpaceGroup.h @@ -30,11 +30,12 @@ struct SpaceGroupOperatorScore { double cc = 0.0; // correlation of I(h) with I(Rh) int n_pairs = 0; // independent reflection pairs the CC was computed from bool present = false; // operator confirmed as a real symmetry of the intensities - // Mean |I1-I2|/(I1+I2) over this operator's pairs - the disagreement the operator implies, with no + // MEDIAN |I1-I2|/(I1+I2) over this operator's pairs - the disagreement the operator implies, with no // sigma in it. Unlike chi^2 and the systematic-b, which are ratios to a merge error model that // drifts with multiplicity, this is a property of the intensities alone; compared against an // operator already confirmed on the same reflections it is what separates a real symmetry from a - // twin law (see max_operator_h_ratio). + // twin law (see max_operator_h_ratio). The median rather than the mean because a merohedral twin + // perturbs EVERY pair while a badly integrated minority perturbs only the tail. double h_stat = 0.0; }; @@ -131,16 +132,20 @@ struct SearchSpaceGroupOptions { // is a non-negligible fraction of I; below the floor the increase is treated as noise, not a twin. double min_systematic_b_for_veto = 0.05; - // Promotion gate on the operator disagreement H = <|I1-I2|/(I1+I2)>, taken as the ratio of the + // Promotion gate on the operator disagreement H = median|I1-I2|/(I1+I2), taken as the ratio of the // operators the promotion ADDS to the operators of the parent group already confirmed on the same // reflections. A real symmetry operator relates equal intensities, so its H matches the parent's // (ratio ~1); a merohedral twin law relates DIFFERENT reflections mixed in proportion alpha, so its - // H is systematically larger. Measured over 27 runs spanning 5 promotion types and 450-1800 images: - // genuine symmetry 0.862-1.219, merohedral twins 1.270-2.084 - a clean gap, and unlike the chi^2 and - // systematic-b ratios (genuine 1.00-3.47 / 1.09-3.89 vs twin 1.35-3.32 / 1.77-4.76, fully - // interleaved) it does not drift with multiplicity, because there is no sigma in it and the parent - // normalisation cancels data quality. For a partial twin over the twin law is (1-2*alpha)/2, so - // the excess over the parent also estimates the twin fraction rather than being a tuned constant. + // H is systematically larger. Unlike the chi^2 and systematic-b ratios (genuine 1.00-3.47 / + // 1.09-3.89 vs twin 1.35-3.32 / 1.77-4.76, fully interleaved) it does not drift with multiplicity, + // because there is no sigma in it and the parent normalisation cancels data quality. + // + // A MEDIAN, not a mean. A merohedral twin mixes every reflection with its twin mate, so it shifts the + // whole distribution of |I1-I2|/(I1+I2); a minority of badly measured reflections shifts only the + // tail. Measured on the same real crystals, moving from the mean to the median leaves genuine + // promotions where they are (1.016 -> 1.013, 1.051 -> 1.067, 1.238 -> 1.231) and pushes every real + // twin UP (1.280 -> 1.622, 1.427 -> 2.010, 1.272 -> 1.447, 1.441 -> 1.522), widening the margin + // around this bound from 2.7% to 17.5%. Battery-neutral: 33 crystals, no point group changed. // // The parent normalisation is what makes this work, and it is not optional. Symmetry-related // reflections never agree exactly on real data - absorption, illumination and partiality differ diff --git a/tests/SearchSpaceGroupTwinTest.cpp b/tests/SearchSpaceGroupTwinTest.cpp index 43f7402b..e9a46e88 100644 --- a/tests/SearchSpaceGroupTwinTest.cpp +++ b/tests/SearchSpaceGroupTwinTest.cpp @@ -149,9 +149,9 @@ namespace { return out; } - // The statistic the promotion is actually decided on: the mean operator disagreement - // H = <|I1-I2|/(I1+I2)> over the operators the promotion ADDS, divided by the mean over the parent - // group's own operators, measured on the same reflections. Mirrors what SearchSpaceGroup computes + // The statistic the promotion is actually decided on: the operator disagreement + // H = median|I1-I2|/(I1+I2) over the operators the promotion ADDS, divided by the same over the + // parent group's own operators, measured on the same reflections. Mirrors what SearchSpaceGroup computes // for the sub -> super step, so a test can report the margin the max_operator_h_ratio bound has. // // The parent normalisation is the whole design, not a detail. An ABSOLUTE per-operator bound cannot @@ -172,6 +172,7 @@ namespace { for (const auto& s : operators) { if (s.n_pairs < 200) // SearchSpaceGroupOptions::min_pairs_for_h continue; + // The median, which is what the promotion is gated on (SearchSpaceGroup). if (parent_ops.count(s.op_triplet_hkl) > 0) { h_parent += s.h_stat; ++n_parent; } else { h_added += s.h_stat; ++n_added; } } -- 2.54.0 From 4895dc10187ffe6a08748dc0fdbbfc1b1cfd1a8c Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 11:45:49 +0200 Subject: [PATCH 046/295] Rotation: let --min-image-cc drop frames that disagree with the merged reference The flag was accepted on rotation data and did nothing - it is read only by the stills merge (Merge.cpp), and the CLI warned about that rather than fixing it. Meanwhile RotationScaleMerge already COMPUTES a per-frame correlation against the merged reference and writes it to the per-image table; nothing acted on it. Wire the two together. A rejected frame has its partials' corr set to 0, which is how a frame already leaves the pipeline - every consumer requires corr > 0, so the combine, the merge and the error model all drop it together. The GPU path reuses the SmoothCorr kernel with a ratio of 0, so one implementation covers both. Off by default (0), and verified bit-identical to the previous binary when off. What it catches, on the two rotation datasets that have a population to catch: a two-lattice crystal - two lattices in two physical AREAS of the sample, so the sweep passes from one to the other and whole blocks of frames measure a different crystal from the one being merged (frames 500-700 index perfectly well at a per-frame CC of 0.22 against 0.47-0.56 either side, in 11 contiguous runs). R_meas 28.6 -> 24.6%, CC1/2 93.6 -> 95.1, high-shell CC 23.4 -> 38.3. a second dataset with 9.5% of frames below CC 0.30: R_meas 24.3 -> 23.4%, CC1/2 92.6 -> 93.4. The criterion is "this frame disagrees with the merged reference", NOT "this frame is off-crystal". It happens to catch both, because a frame that measures nothing and a frame that measures a DIFFERENT crystal fail the same test, and it does not need to know which. For the two-area case that is a workaround, not a treatment: it recovers one crystal by discarding the other, where processing the two as separate sweeps would keep both. The frame-block structure is clean enough that such a split could be detected automatically. WHY THERE IS NO DEFAULT. The per-frame CC is not comparable between datasets - it is as much a measure of data quality as of frame validity. Measured medians across the battery run from 0.30 to 0.81, so one absolute bound removes 13 frames from one dataset and 584 of 1800 from another: battery at --min-image-cc 30, 33 crystals: no point group changed (30/33), four crystals clearly better (one +5.4 CC1/2 points, the two-lattice case above, and ISa gains of 1.3-4.6 on three others) - and one healthy crystal lost a third of its frames and with them its high-resolution shell (CC1/2_hi 26.2 -> 2.0). This is the same trap as an absolute bound on any per-operator or per-frame agreement statistic, and the same one the per-frame scale guard avoids by measuring against the run's own median. A principled version would cut on the SHAPE of the per-frame CC distribution - a dataset with a bad subpopulation is bimodal, a uniformly weak one is not - rather than on an absolute value. Until that exists this stays opt-in, and the per-image CC it keys on is already in the _image.dat table for anyone choosing a value. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.cpp | 33 ++++++++++++++++++- .../scale_merge/RotationScaleMerge.h | 6 ++++ rugnux/rugnux_cli.cpp | 4 --- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index b37f3237..c8e3f4ae 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -29,7 +29,8 @@ namespace { // These mirror the per-image ScaleOnTheFly / Merge rocking-curve physics verbatim so this flat // implementation is numerically identical - see the comments there for the details. - constexpr size_t MIN_REFLECTIONS = 20; // per-frame scale needs at least this many + constexpr size_t MIN_REFLECTIONS = 20; + constexpr int64_t MIN_REFLECTIONS_FOR_IMAGE_CC = 20; // below this a frame's CC means nothing // per-frame scale needs at least this many constexpr double SCALE_ROBUST_K = 3.0; // Cauchy loss scale (sigma units) for the per-frame G fit // A fitted per-frame scale below this fraction of the run's median is not a measurement of anything: @@ -175,6 +176,7 @@ RotationScaleMerge::RotationScaleMerge(const DiffractionExperiment &experiment, merge_friedel = s.GetMergeFriedel(); capture_uncertainty_coeff = s.GetCaptureUncertaintyCoeff(); min_captured_fraction = s.GetMinCapturedFraction(); + min_cc_for_image = s.GetMinCCForImage(); reject_nsigma = s.GetOutlierRejectNsigma(); reject_outliers = reject_nsigma > 0.0; rfree_fraction = s.GetRfreeFraction(); @@ -1993,6 +1995,35 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, } FinalizePerFrameScale(cc, cc_n, partial_scaled); + // --- 2b. Drop frames that do not agree with the merged reference (--min-image-cc). --- + if (min_cc_for_image > 0.0) { + std::vector reject(n_frames, 0); + int n_rejected = 0; + for (int f = 0; f < n_frames; ++f) + if (std::isfinite(cc[f]) && cc_n[f] >= MIN_REFLECTIONS_FOR_IMAGE_CC && cc[f] < min_cc_for_image) { + reject[f] = 1; + ++n_rejected; + } + if (n_rejected > 0) { + // Zeroing corr is how a frame leaves the pipeline: every consumer requires corr > 0, so the + // frame's partials stop being usable for the combine, the merge and the error model alike. + bool rejected_on_gpu = false; +#ifdef JFJOCH_USE_CUDA + if (gpu_active_) { + std::vector ratio(n_frames, 1.0); + for (int f = 0; f < n_frames; ++f) if (reject[f]) ratio[f] = 0.0; + gpu_->SmoothCorr(reject.data(), ratio.data()); + rejected_on_gpu = true; + } +#endif + if (!rejected_on_gpu) + for (auto &o : partials) + if (reject[o.frame]) o.corr = 0.0f; + logger.Info("Rejected {} of {} frames correlating below {:.2f} with the merged reference", + n_rejected, n_frames, min_cc_for_image); + } + } + // --- 3. 3D combine of per-frame partials into fulls (fulls inherit their ASU group here). --- bool combined_on_gpu = false; bool scaled_fulls_on_gpu = false; diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index 2dd3be38..adb37fa9 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -100,6 +100,12 @@ private: bool merge_friedel = true; double capture_uncertainty_coeff = 0.0; double min_captured_fraction = 0.0; + + // Drop a frame's observations entirely when the frame disagrees with the merged reference below this + // correlation (--min-image-cc). A mis-centred or off-crystal frame still produces spots, still + // indexes and still integrates - it just measures something that is not the crystal's diffraction, + // and nothing downstream removes it. 0 = off. + double min_cc_for_image = 0.0; double reject_nsigma = 0.0; bool reject_outliers = false; double rfree_fraction = 0.0; diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 383625d9..d1e1e66f 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -1425,10 +1425,6 @@ int main(int argc, char **argv) { scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(rotation_indexing ? 1.0 : 0.0)); scaling_settings.ForcedMosaicity(forced_mosaicity_arg); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction - // The per-image CC filter is consumed by the stills merge (MergeOnTheFly) only - RotationScaleMerge - // never reads it - so on rotation data it would otherwise be accepted and silently do nothing. - if (min_image_cc > 0.0 && rotation_indexing) - logger.Warning("--min-image-cc is ignored for rotation data: it filters the stills merge only"); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or(rotation_indexing ? REJECT_OUTLIERS_DEFAULT_NSIGMA : 0.0)); -- 2.54.0 From 6f4917dcee9a5beec5efadfb9aac3e782c7b485f Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 11:59:37 +0200 Subject: [PATCH 047/295] rugnux: adaptive spot detection is the default for rotation data too It was held back because a 33-crystal rotation battery showed it breaking three crystals deterministically - a lost space group, a halved indexing rate and a collapsed merge. None of those causes turned out to be in detection. The extra spots adaptive finds are real. Measured per spot against a finder-neutral local background: 64% recur at the same position on the adjacent frame (chance rate 0.5%) with 2-frame rocking curves, and 0.00% would fail a conventional local SNR >= 4 test, median local SNR 34. What they include is genuine peaks belonging to no lattice the indexer found, and the damage they did scaled with their absolute COUNT (80.6 per frame against 36.8 for the fixed finder), not with their quality - which is why nothing aimed at judging individual spots ever worked. The three failures fell to fixes elsewhere: merge collapsed - a per-frame scale free to collapse toward zero amplified two junk frames by 546x (704098712, ec7a82613). Not a detection problem at all: the fixed-threshold finder trips the same bug on a different frame range. space group lost - the per-image geometry refinement was dragged 2.3-2.8 deg off by the weak-spot tail in an unweighted fit; weighting each spot by how strong it is FOR ITS RESOLUTION fixed it (ae126c3d5), and gained a point group for the fixed finder too. indexing halved - gone with the same two; that crystal is now better under adaptive (CC1/2 92.6 -> 95.9, high-shell 44.9 -> 56.4). Battery, 33 crystals, adaptive vs the fixed finder: exact space group matching XDS 26/33 vs 25/33 point group matching XDS 29/33 vs 30/33 ISa better on 6 crystals recovers a screw axis the other misses (P321 -> P3121) The one point group it loses is a tetragonal crystal where adaptive collects 2.4x the observations at better R_meas (21.4% vs 28.3%) and better ISa (4.58 vs 2.91), and forced to the right group gives CC1/2 99.2% at multiplicity 10.7 - matching XDS. Only the automatic symmetry call fails there, and six candidate causes have been measured and refuted (mixed indexing hands, off-crystal frames, a badly integrated minority, radiation damage, pseudo-tetragonality, uncorrected anisotropy). It is left as the subgroup, which is the recoverable direction: -S gives XDS-quality data from the same run, whereas the failures this unblocks were not recoverable. --no-adaptive-spots reverts to the fixed-threshold finder. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CPU_DATA_ANALYSIS.md | 2 +- rugnux/rugnux_cli.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index 234dfff2..0bae209b 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -187,7 +187,7 @@ Special cases: ### 3.2 Adaptive (self-calibrating) detection -The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. The **adaptive** mode (`--adaptive-spots`; the default for stills in `rugnux` and in the viewer, `--no-adaptive-spots` reverts) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. Rotation data keeps the fixed-threshold finder by default: across a 33-crystal rotation battery adaptive detection helped four hard crystals but deterministically broke three (a lost space group, a halved indexing rate, a collapsed merge). +The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. The **adaptive** mode (`--adaptive-spots`; the default in `rugnux` and in the viewer, `--no-adaptive-spots` reverts) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. It is the default for rotation data as well: it was held back while a 33-crystal battery showed it breaking three crystals (a lost space group, a halved indexing rate, a collapsed merge), but none of those causes lay in detection. Adaptive finds roughly twice as many spots and they are real — 64 % recur at the same position on the adjacent frame against a 0.5 % chance rate, and none would fail a conventional local-SNR test — but they include genuine peaks that belong to no indexed lattice, and the damage they did scaled with their absolute *count*. Guarding the per-frame scale against collapse and weighting each spot by how strong it is for its resolution (§ per-image refinement) removed all three failures; adaptive now matches the exact space group on 26 of 33 crystals against 25 for the fixed-threshold finder, with better ISa on six. Pixels are binned into the same resolution rings as the azimuthal integrator (§2). For each ring a robust background is estimated in three passes: one plain pass over all valid pixels, then two $\sigma$-clipping passes that keep only pixels within $\pm 3\sigma$ of the current ring mean (removing the Bragg peaks from the background estimate). This yields a per-ring background mean $\mu_b$ and scatter $\sigma_b$. diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index d1e1e66f..cde3d25e 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -66,7 +66,7 @@ void print_usage() { std::cout << " --spot-sigma Noise sigma level for spot finding (default: 3.0)" << std::endl; std::cout << " --spot-threshold Photon count threshold for spot finding (default: 10)" << std::endl; std::cout << " --min-pix-per-spot Minimum connected strong pixels per spot. If omitted, min-pix is chosen PER IMAGE (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction is kept. Give an explicit value to force a fixed min-pix instead." << std::endl; - std::cout << " --adaptive-spots Self-calibrating detection (DEFAULT for stills): the strong-pixel threshold comes from each image's own per-resolution-ring noise instead of the fixed --spot-threshold, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning). Rotation data keeps the fixed-threshold finder unless this is given." << std::endl; + std::cout << " --adaptive-spots Self-calibrating detection (DEFAULT): the strong-pixel threshold comes from each image's own per-resolution-ring noise instead of the fixed --spot-threshold, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)." << std::endl; std::cout << " --no-adaptive-spots Turn adaptive detection off and use the fixed --spot-threshold / --spot-sigma finder instead" << std::endl; std::cout << " --spot-false-pixels Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl; std::cout << " --spot-high-resolution High resolution limit for spot finding. If omitted, stills extend as far as the detector reaches (no resolution clipping) and rotation data keeps a 1.5 A limit." << std::endl; @@ -1495,7 +1495,7 @@ int main(int argc, char **argv) { spot_settings.min_pix_per_spot = 2; if (rotation_indexing && !d_min_spot_finding.has_value()) d_min_spot_finding = 1.5f; - spot_settings.adaptive_threshold = adaptive_spots.value_or(!rotation_indexing); + spot_settings.adaptive_threshold = adaptive_spots.value_or(true); spot_settings.high_resolution_limit = d_min_spot_finding; spot_settings.false_pixels_per_frame = false_pixels_per_frame; if (d_max_spot_finding > 0.0f) -- 2.54.0 From b81c6f00b71b2e25218ef407650fbaaccedbee55 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 17:49:14 +0200 Subject: [PATCH 048/295] reader: take the stored image bit depth from the file, not the DECTRIS default DetectorSetup hardcodes bit_depth_image = 16 for every DECTRIS detector (DetectorSetup.cpp:78), and GetByteDepthImage() consults that BEFORE the depth the reader takes from the file - so a file storing 32-bit images had its overflow computed as a 16-bit one. With the reader also declaring the images signed, GetOverflow() returned INT16_MAX and GetSaturationLimit() became min(file value, 32767). Every count above 32767 was therefore marked saturated, and because the integration accept gate requires ALL inner pixels valid, the whole reflection was discarded. That silently removes the strongest reflections of a strong crystal - the low-resolution ones that anchor scaling - while the file itself declares saturation at 105000-133000. Measured on a lysozyme rotation set (200 frames), before -> after: saturated pixels per frame 0.815 -> 0.000 brightest accepted pixel 32738 -> 87633 mean per-frame maximum 26218 -> 37390 i.e. the ceiling was exactly INT16_MAX and nothing genuine reached it. Which datasets this touches depends on how bright they are: measured pixels above the old ceiling range from 0.0 per frame on some rotation sets to 6.1 on others, so the fix is a no-op on weak data and only ever adds reflections. Rotation battery, 33 crystals: no point group changed (30/33 before and after) and no run failed. Four crystals move on quality, in both directions - ISa 1.90 -> 2.40 and 3.29 -> 4.80 on two, 2.97 -> 1.85 and 20.83 -> 18.47 on two others; three of the four are the battery's known weak or run-to-run-unstable crystals. The one strong crystal that moves gains 136 observations out of 1.9 million and loses 2.4 ISa: the reflections restored are by construction the brightest ones, and they carry the systematic error that the strongest reflections always carry. That is a real cost, but it is the cost of MEASURING them rather than discarding them unseen, and a lower asymptotic I/sigma on data that are now complete is preferable to a flattering one on data that quietly are not. Only the offline file reader is affected; the online path builds its detector setup from configuration. Co-Authored-By: Claude Opus 5 (1M context) --- reader/HDF5MetadataSource.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/reader/HDF5MetadataSource.cpp b/reader/HDF5MetadataSource.cpp index 75eac328..2a3ca7ea 100644 --- a/reader/HDF5MetadataSource.cpp +++ b/reader/HDF5MetadataSource.cpp @@ -661,6 +661,15 @@ HDF5MetadataSource::OpenResult HDF5MetadataSource::Open(const std::string &filen if (master_file->Exists("/entry/instrument/detector/sensor_material")) detector.SensorMaterial(master_file->GetString("/entry/instrument/detector/sensor_material")); detector.SaturationLimit(master_file->GetInt("/entry/instrument/detector/saturation_value")); + // The container depth of the stored images, NOT the detector's counter depth. DetectorSetup + // defaults DECTRIS to 16 bits, and GetByteDepthImage() prefers that over the value the image + // format carries - so without this an EIGER2 file that stores 32-bit images has its overflow + // computed as a 16-bit one and every count above 32767 is called saturated. The integration + // accept gate then drops the WHOLE reflection, which silently removes the strongest + // reflections of a strong crystal (measured on a lysozyme set: max accepted pixel 32738 + // against a declared saturation of 108833). + if (master_file->Exists("/entry/instrument/detector/bit_depth_image")) + detector.BitDepthImage(master_file->GetInt("/entry/instrument/detector/bit_depth_image")); detector.MinFrameTime(std::chrono::microseconds(0)); detector.MinCountTime(std::chrono::microseconds(0)); detector.ReadOutTime(std::chrono::nanoseconds(0)); -- 2.54.0 From eb70684fa9bbfc8032bd3f8a5589b2f84819bc3d Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 17:59:47 +0200 Subject: [PATCH 049/295] rugnux: report how close a symmetry axis lies to the spindle A rotation sweep never records the reflections whose reciprocal vector lies within the Bragg angle of the spindle - the blind cusp. Symmetry normally supplies them from an equivalent elsewhere in reciprocal space, so the hole closes. It cannot when a symmetry axis IS the spindle: the cusp is then mapped onto itself, every reflection in it is equivalent only to other reflections in it, and it stays empty however long the sweep runs. The user can fix this at the microscope - re-mount, or add a sweep on another axis - but only if they are told, and nothing in the output mentioned it. Report the smallest angle between any proper rotation axis of the adopted space group and the goniometer axis, always on rotation data, and warn when it falls under 15 deg. The axis is found by projecting onto each operator's invariant direction (the sum of its powers annihilates everything else) and mapping that fractional direction through the refined lattice into the lab frame; the angle is invariant under the sweep, so the reference orientation is enough. Cross-check: this reports 30.6 deg for a crystal whose 4-fold an independent analysis of the XDS orientation matrix put at 30.5 deg. Measured on three rotation sets: 13.6 deg (2-fold, warns), 16.2 deg (2-fold, 99.7% complete) and 30.6 deg (4-fold). The 15 deg bound is practical rather than derived - the blind cone's half-angle is the maximum Bragg angle, ~15 deg for 2 A data at 1 A wavelength - and the wording says what the diagnostic can honestly support: the angle is a risk indicator, the loss is confined to the cone rather than spread over the data, and overall completeness may still look reasonable while the region near the spindle is empty. It does not promise a completeness number, because across those three sets the overall figure does not track the angle (99.7% at 16.2 deg, 92.6% at 30.6 deg). Co-Authored-By: Claude Opus 5 (1M context) --- rugnux/Rugnux.cpp | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 71a9a68b..e9ecfafd 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -291,6 +291,60 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { return RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); } +namespace { + // Angle between the crystal's symmetry axes and the spindle. A rotation sweep never records the + // reflections whose reciprocal vector lies close to the spindle - the blind cusp - and normally + // symmetry fills it in from an equivalent elsewhere in reciprocal space. It cannot when a symmetry + // axis IS the spindle: the cusp then maps onto itself, every reflection in it is equivalent only to + // other reflections in it, and the hole stays empty however long the sweep runs. Returns the + // smallest angle in degrees between any proper rotation axis of the space group and the goniometer + // axis, with the order of the axis that achieves it. + std::optional> ClosestSymmetryAxisToSpindle(const gemmi::SpaceGroup &sg, + const CrystalLattice &lattice, + const Coord &spindle) { + const double spindle_len = spindle.Length(); + if (spindle_len < 1e-9) + return std::nullopt; + std::optional> best; + for (const auto &op : sg.operations().derive_symmorphic().sym_ops) { + if (op.rot == gemmi::Op::identity().rot) + continue; + // Order of the rotation, then project onto its invariant direction by summing its powers: + // (1/n) sum_k W^k annihilates everything except the axis. + gemmi::Op acc = gemmi::Op::identity(), w{op.rot, {0, 0, 0}, op.notation}; + int order = 0; + gemmi::Op cur = gemmi::Op::identity(); + std::array, 3> sum{}; + for (int k = 0; k < 6; ++k) { + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + sum[i][j] += static_cast(cur.rot[i][j]) / gemmi::Op::DEN; + cur = cur.combine(w).wrap(); + ++order; + if (cur.rot == gemmi::Op::identity().rot) + break; + } + if (order < 2) + continue; + // Any non-degenerate column of the projector is the axis in fractional direct coordinates. + for (int c = 0; c < 3; ++c) { + const Coord axis = lattice.Vec0() * static_cast(sum[0][c]) + + lattice.Vec1() * static_cast(sum[1][c]) + + lattice.Vec2() * static_cast(sum[2][c]); + const double len = axis.Length(); + if (len < 1e-6 * lattice.Vec0().Length()) + continue; + const double cosang = std::abs((axis * spindle) / (len * spindle_len)); + const double ang = std::acos(std::min(1.0, cosang)) * 180.0 / M_PI; + if (!best.has_value() || ang < best->first) + best = std::make_pair(ang, order); + break; + } + } + return best; + } +} + ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, bool geometry_prepass) { Logger logger("Rugnux"); ProcessResult result; @@ -1370,6 +1424,39 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b result.twinning.laue_class_was_chosen_by_promotion = promoted_point_group; stats_text << TwinningAnalysisToText(result.twinning) << "\n"; + // Symmetry axis vs spindle. Reported always on rotation data (it is a property of how the + // crystal was mounted, which the user can change), warned about when the two nearly coincide. + if (experiment_.IsRotationIndexing() && twin_sg && end_msg.rotation_lattice.has_value()) { + if (const auto gonio = experiment_.GetGoniometer()) { + const auto closest = ClosestSymmetryAxisToSpindle(*twin_sg, *end_msg.rotation_lattice, + gonio->GetAxis()); + if (closest.has_value()) { + // Practical bound, not a derived one: the blind cone's half-angle is the maximum + // Bragg angle (~15 deg for 2 A data at 1 A), and measured on this battery a 13.6 deg + // case shows the loss while a 16.2 deg one is 99.7% complete. + constexpr double WARN_DEG = 15.0; + const auto [angle, order] = *closest; + if (angle < WARN_DEG) { + const std::string msg = fmt::format( + "The crystal's {}-fold axis is only {:.1f} deg from the spindle. A rotation sweep " + "never records the reflections whose reciprocal vector lies within the Bragg angle " + "of the spindle, and symmetry normally supplies them from an equivalent elsewhere; " + "it cannot here, because that axis maps the blind region onto itself. Those " + "reflections stay missing however long the sweep runs. The loss is confined to " + "that cone rather than spread over the data, so overall completeness may still " + "look reasonable - check it near the spindle direction. A second sweep on a " + "different axis, or re-mounting, recovers them.", + order, angle); + logger.Warning("{}", msg); + stats_text << " !! " << msg << "\n\n"; + } else { + stats_text << "Closest symmetry axis to the spindle: " << order << "-fold at " + << std::fixed << std::setprecision(1) << angle << " deg\n\n"; + } + } + } + } + // Indexing-ambiguity (alternative-indexing) advisory. When the lattice metric symmetry exceeds // the Laue symmetry the crystal can be validly indexed in several hands related by twin-law // operators. For an obvious merohedral case (P3/P4/P6...) users expect this; but a PSEUDO-merohedral -- 2.54.0 From f2b92e3f4dc22f6b179923a2686cf70d88f1d8b4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 20:03:43 +0200 Subject: [PATCH 050/295] rugnux: --search-min-zeta drops badly-measured observations from the symmetry search zeta is the sine of the angle between a reflection's rocking path and the spindle. Near 0 the reflection crosses the Ewald sphere almost tangentially, spends many frames in diffracting position and is measured worst. The de-novo space-group search asks how EQUAL an operator's paired intensities are, so its answer is dominated by whichever reflections are measured worst - and when the spindle lies in a lattice plane, an operator that permutes the two in-plane axes samples a different mixture of measurement qualities than one that only flips signs. That is not a fair comparison, and it can make a real symmetry operator look like a twin law. Measured on a thaumatin set mounted that way (its 4-fold is 88.9 deg from the spindle), the added operators' disagreement is 1.74x the parent's over pairs where both reflections have zeta < 0.85 and 1.003x - i.e. the symmetry is exact - over pairs where both are above it. The search consequently refuses the 422 promotion and merges the crystal in P222, while the same data forced to the right group give CC1/2 99.2% at multiplicity 10.7, matching XDS. With the option the de-novo pass ignores those observations (the final merge keeps everything - there completeness is the point): zeta cut observations ignored H ratio adopted 0 (off) - 1.47 P222 0.5 1620648 1.44 P222 0.7 3006013 1.34 P21212 0.85 4536724 promoted P4212 (correct point group) OFF BY DEFAULT, and it must stay off, because the same cut costs four other crystals their space group (P41212 -> P212121, I23 -> P2, I23 -> I222 twice): at 0.85 it discards 40-80% of all observations, which on a crystal whose geometry is not the problem simply starves the search. Two independent implementations - filtering the pairs that enter the statistic, and filtering the observations that enter the merge - trade exactly the same crystals, so this is a property of the cut and not of where it is applied. Verified bit-identical to the previous binary when off. The companion diagnostic is already there: the run now reports how close a symmetry axis lies to the spindle, which is the geometry that makes this option worth reaching for. Implementation note for anyone tempted by the cheaper route: excluding these observations from the ASU grouping alone does NOT work. The 3D combine selects partials on corr, not on their group, so their intensity still reaches the fulls and the merged intensities are unchanged - measured, the statistic did not move by 0.03 while 67% of observations were nominally excluded. Zeroing corr is what removes an observation from the combine, the merge and the error model alike. Co-Authored-By: Claude Opus 5 (1M context) --- common/ScalingSettings.cpp | 12 +++++++++ common/ScalingSettings.h | 6 +++++ .../scale_merge/RotationScaleMerge.cpp | 25 +++++++++++++++++++ .../scale_merge/RotationScaleMerge.h | 10 ++++++++ rugnux/rugnux_cli.cpp | 8 ++++++ 5 files changed, 61 insertions(+) diff --git a/common/ScalingSettings.cpp b/common/ScalingSettings.cpp index 9a43f393..617ff2ac 100644 --- a/common/ScalingSettings.cpp +++ b/common/ScalingSettings.cpp @@ -89,6 +89,18 @@ double ScalingSettings::GetMinCCForImage() const { return min_cc_for_image; } +double ScalingSettings::GetSearchMinZeta() const { + return search_min_zeta; +} + +ScalingSettings &ScalingSettings::SearchMinZeta(double input) { + if (input < 0.0 || input >= 1.0) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "Search zeta limit must be in [0,1)"); + search_min_zeta = input; + return *this; +} + ScalingSettings &ScalingSettings::MinCCForImage(double input) { if (input < 0.0 || input > 1.0) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Min CC for image must be between 0 and 1"); diff --git a/common/ScalingSettings.h b/common/ScalingSettings.h index abef9dfa..60b98b0d 100644 --- a/common/ScalingSettings.h +++ b/common/ScalingSettings.h @@ -28,6 +28,10 @@ class ScalingSettings { // min_partiality, which gates individual partials; this gates the assembled full. 0 = off (baseline). double min_captured_fraction = 0.0; double min_cc_for_image = 0.0; + + // Exclude observations whose Lorentz geometry |zeta| falls below this from the DE-NOVO space-group + // search merge only (see RotationScaleMerge::search_min_zeta). 0 = off. + double search_min_zeta = 0.0; double outlier_reject_nsigma = 0.0; // per-observation merge outlier rejection (XDS/DIALS-style); 0 = off, e.g. 6 enables // Scale fulls: after the rotation 3D combine, refit a per-frame scale on the combined fulls (XDS @@ -92,6 +96,7 @@ public: ScalingSettings& CaptureUncertaintyCoeff(double input); ScalingSettings& MinCapturedFraction(double input); ScalingSettings& MinCCForImage(double min_cc_for_image); + ScalingSettings& SearchMinZeta(double search_min_zeta); ScalingSettings& OutlierRejectNsigma(double input); ScalingSettings& ScaleFulls(bool input); ScalingSettings& AbsorptionIter(int input); @@ -127,6 +132,7 @@ public: [[nodiscard]] double GetCaptureUncertaintyCoeff() const; [[nodiscard]] double GetMinCapturedFraction() const; [[nodiscard]] double GetMinCCForImage() const; + [[nodiscard]] double GetSearchMinZeta() const; [[nodiscard]] double GetOutlierRejectNsigma() const; [[nodiscard]] bool GetScaleFulls() const; [[nodiscard]] int GetAbsorptionIter() const; diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index c8e3f4ae..9b698dbe 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -177,6 +177,7 @@ RotationScaleMerge::RotationScaleMerge(const DiffractionExperiment &experiment, capture_uncertainty_coeff = s.GetCaptureUncertaintyCoeff(); min_captured_fraction = s.GetMinCapturedFraction(); min_cc_for_image = s.GetMinCCForImage(); + search_min_zeta = s.GetSearchMinZeta(); reject_nsigma = s.GetOutlierRejectNsigma(); reject_outliers = reject_nsigma > 0.0; rfree_fraction = s.GetRfreeFraction(); @@ -1995,6 +1996,30 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, } FinalizePerFrameScale(cc, cc_n, partial_scaled); + // --- 2a. On the de-novo search pass only, drop observations whose Lorentz geometry is poor. + // Zeroing corr is what removes an observation everywhere: the 3D combine, the merge and the + // error model all require corr > 0. Excluding them from the ASU grouping alone is NOT + // enough - the combine selects on corr, so their intensity would still reach the fulls. --- + if (for_search && search_min_zeta > 0.0) { + int64_t n_dropped = 0; + for (auto &o : partials) + if (!(std::isfinite(o.zeta) && o.zeta >= search_min_zeta)) { + if (std::isfinite(o.corr) && o.corr > 0.0f) ++n_dropped; + o.corr = 0.0f; + } +#ifdef JFJOCH_USE_CUDA + if (gpu_active_ && n_dropped > 0) { + std::vector corr(partials.size()); + for (size_t i = 0; i < partials.size(); ++i) corr[i] = partials[i].corr; + gpu_->SetCorr(corr.data()); + } +#endif + if (n_dropped > 0) + logger.Info("Space-group search: ignoring {} observations with |zeta| < {:.2f} " + "(they cross the Ewald sphere near-tangentially and are measured worst)", + n_dropped, search_min_zeta); + } + // --- 2b. Drop frames that do not agree with the merged reference (--min-image-cc). --- if (min_cc_for_image > 0.0) { std::vector reject(n_frames, 0); diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index adb37fa9..f93ec27c 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -106,6 +106,16 @@ private: // indexes and still integrates - it just measures something that is not the crystal's diffraction, // and nothing downstream removes it. 0 = off. double min_cc_for_image = 0.0; + + // Exclude observations with |zeta| below this from the DE-NOVO SEARCH merge only (the final merge + // keeps everything). zeta is the sine of the angle between a reflection's rocking path and the + // spindle: near 0 it crosses the Ewald sphere almost tangentially, spends many frames in + // diffracting position and is measured badly. The symmetry search compares how equal an operator's + // paired intensities are, so it is answered by whichever reflections are worst measured - and when + // the spindle lies in a lattice plane, an operator permuting the two in-plane axes samples a + // different mixture of qualities than one that only flips signs, which is not a fair comparison. + // Unlike a bound on I/sigma this is pure geometry, identical in meaning on every dataset. 0 = off. + double search_min_zeta = 0.0; double reject_nsigma = 0.0; bool reject_outliers = false; double rfree_fraction = 0.0; diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index cde3d25e..30388b68 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -109,6 +109,7 @@ void print_usage() { std::cout << " --min-captured-fraction rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl; std::cout << " --mosaicity Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl; std::cout << " --reject-outliers Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl; + std::cout << " --search-min-zeta De-novo space-group search only: ignore observations whose Lorentz geometry |zeta| is below this (0-1). Reflections crossing the Ewald sphere near-tangentially are measured badly and can make a symmetry operator look wrong (default: 0 = use all)" << std::endl; std::cout << " --min-image-cc Per-image CC limit in percent (default: no limit)" << std::endl; std::cout << " --scaling-iterations Number of scaling iterations with no reference data (default: 3)" << std::endl; std::cout << " -z, --reference-mtz Reference MTZ file" << std::endl; @@ -152,6 +153,7 @@ enum { OPT_MAX_SPOTS, OPT_MIN_PARTIALITY, OPT_MIN_IMAGE_CC, + OPT_SEARCH_MIN_ZETA, OPT_SCALING_ITERATIONS, OPT_SCALING_HIGH_RESOLUTION, OPT_RESOLUTION_CUTOFF, @@ -263,6 +265,7 @@ static option long_options[] = { {"min-captured-fraction", required_argument, nullptr, OPT_MIN_CAPTURED_FRACTION}, {"mosaicity", required_argument, nullptr, OPT_MOSAICITY}, {"min-image-cc", required_argument, nullptr, OPT_MIN_IMAGE_CC}, + {"search-min-zeta", required_argument, nullptr, OPT_SEARCH_MIN_ZETA}, {"scaling-iterations", required_argument, nullptr, OPT_SCALING_ITERATIONS}, {"scaling-high-resolution", required_argument, nullptr, OPT_SCALING_HIGH_RESOLUTION}, {"resolution-cutoff", required_argument, nullptr, OPT_RESOLUTION_CUTOFF}, @@ -527,6 +530,7 @@ int main(int argc, char **argv) { std::optional capture_uncertainty_arg; // explicit --capture-uncertainty; default depends on rot3d std::optional forced_mosaicity_arg; // diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed double min_image_cc = 0.0; + double search_min_zeta = 0.0; // --search-min-zeta; de-novo search merge only int64_t scaling_iter = 3; std::optional forced_rotation_lattice; std::optional refine_geometry; // --refine-geometry[=N]: stills global geometry-refinement pass @@ -851,6 +855,9 @@ int main(int argc, char **argv) { case OPT_MIN_IMAGE_CC: min_image_cc = parse_double_arg(optarg, "--min-image-cc", logger); break; + case OPT_SEARCH_MIN_ZETA: + search_min_zeta = parse_double_arg(optarg, "--search-min-zeta", logger); + break; case OPT_SCALING_HIGH_RESOLUTION: d_min_scale_merge = atof(optarg); break; @@ -1425,6 +1432,7 @@ int main(int argc, char **argv) { scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(rotation_indexing ? 1.0 : 0.0)); scaling_settings.ForcedMosaicity(forced_mosaicity_arg); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction + scaling_settings.SearchMinZeta(search_min_zeta); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or(rotation_indexing ? REJECT_OUTLIERS_DEFAULT_NSIGMA : 0.0)); -- 2.54.0 From 25458265d3c3a52e3fd2e5ea9d3483c034e272ee Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 21:11:05 +0200 Subject: [PATCH 051/295] Space-group search: ask twice - all observations, and only the well-measured ones --search-min-zeta rescues a point group that the full merge cannot confirm, but used on its own it is a trade: on the crystal it was built for it recovers the correct 422, and on four others it costs the space group outright, because discarding 40-80% of the observations starves operator correlations that were perfectly healthy. Both ways of applying it - filtering the pairs that enter the statistic, and filtering the observations that enter the merge - trade the SAME crystals, so the cut itself is the problem, not where it is applied. Filip's observation makes it one-way: every disagreement between the two is a LOST operator, never an invented one. Discarding observations can starve a correlation; it cannot manufacture symmetry that is not there. So run the search on both merges and keep whichever found MORE symmetry, and the failure mode disappears - each arm rescues the other exactly where it fails. crystal all observations Lorentz-filtered adopted thaumatin (weak) 222 422 422 tetragonal lysozyme 422 222 422 cubic insulin x3 23 2 / 222 23 The filtered merge is used ONLY to rescue the point group. The screw and centering determination always comes from the merge with all the observations, because systematic absences are decided by the WEAK reflections and the filter throws most of them away. Preferring the filtered arm on a tie is not a conservative choice, it is a wrong one: it cost four crystals their screw axes (P2(1) read as P2, P4(1)2(1)2 as P42(1)2) with the point group and every intensity statistic identical - a regression invisible to CC1/2, R_meas and ISa. Where the two find the same ORDER but different symmetry, nothing can prefer one, so the run says so: it names both space groups, states that the data do not decide, reports which one processing continued in, and gives the flag to force the other. Two candidates of the same order imply different molecular replacement searches, and trying both is cheap next to reprocessing - much cheaper than a confident wrong answer. Rotation battery, 33 crystals, both spot finders: fixed-threshold finder 30/33 - ZERO crystals differ from the single search adaptive finder 30/33 - the same three mismatches, gap CLOSED The adaptive finder now matches the fixed-threshold one exactly, which it has not done before: its last remaining loss was the thaumatin set whose 4-fold sits 88.9 deg from the spindle, and it now reads P42(1)2 (all-observation merge -> 222, Lorentz-filtered -> 422, higher taken). A merohedral twin stays refused in BOTH arms at all three frame ranges where it over-promotes, and at one of them the second opinion is strictly better than shipping behaviour - the full merge collapses to P1 where the filtered one finds the correct H3. Cost is the extra scale-combine-merge on already-ingested partials, with no re-integration: 47.2 s against 47.8 s on the same crystal back to back. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.h | 5 ++ rugnux/Rugnux.cpp | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index f93ec27c..2e9b7c40 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -72,6 +72,11 @@ public: // >= 1 without cutting the final in-symmetry merge. Reset to the manual limit afterwards. void SetDMinLimit(std::optional d_min_A) { d_min_limit = d_min_A; } + // Toggle the search-pass Lorentz filter (see search_min_zeta) between Run() calls, so the caller can + // produce both a filtered and an unfiltered search merge from the same ingested partials. + void SetSearchMinZeta(double zeta) { search_min_zeta = zeta; } + [[nodiscard]] double GetSearchMinZeta() const { return search_min_zeta; } + private: // One integrated observation - a per-frame partial during scaling/combine, or a combined full during // scale-fulls/merge. Flat (not nested per image); a POD so the arrays translate straight to CUDA. diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index e9ecfafd..7ebc3be7 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -1204,6 +1204,72 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b sg_opts.lattice_system = end_msg.rotation_lattice_type->crystal_system; auto sg_search = SearchSpaceGroup(sm.merged, sg_opts); + // Second opinion from a merge that keeps only well-measured observations (see + // RotationScaleMerge::search_min_zeta), and take whichever search found MORE symmetry. + // The two disagree in one direction only: every crystal on which the Lorentz filter changes + // the answer LOSES an operator, because discarding 40-80% of the observations starves the + // operator correlations - it never invents one. So the higher of the two is the safe pick, + // and it costs nothing on crystals where the filter is irrelevant. Both searches keep their + // vetoes, so a merohedral twin stays refused in each (verified on a real twin at the three + // frame ranges where it over-promotes: refused in both arms every time). + if (rsm && rsm->GetSearchMinZeta() > 0.0 && !sg_search.point_group_hm.empty()) { + const double zeta = rsm->GetSearchMinZeta(); + rsm->SetSearchMinZeta(0.0); + auto sm_all = scale_and_merge("P1, all observations", true); + rsm->SetSearchMinZeta(zeta); + const auto alt = SearchSpaceGroup(sm_all.merged, sg_opts); + // Point-group order = the number of rotations, which is what sym_ops holds (centering + // lives in cen_ops). Taken from the space group the search chose - a point-group symbol + // like "422" is not a space-group name and cannot be looked up. + const auto order_of = [](const SearchSpaceGroupResult &r) -> size_t { + return r.best_space_group.has_value() + ? r.best_space_group->operations().sym_ops.size() : 0; + }; + logger.Info("Space-group search: all-observation merge -> {} (order {}), " + "Lorentz-filtered -> {} (order {})", + alt.point_group_hm.empty() ? "?" : alt.point_group_hm, order_of(alt), + sg_search.point_group_hm.empty() ? "?" : sg_search.point_group_hm, + order_of(sg_search)); + // The filtered merge is used ONLY to rescue a point group the full merge failed to + // confirm. Everything else - and the screw/centering determination in particular - comes + // from the merge with all the observations, because systematic absences are decided by + // the WEAK reflections and the filter throws most of them away. Preferring the filtered + // arm on a tie cost four crystals their screw axes (P2(1) read as P2) for exactly that + // reason, with the point group and every intensity statistic identical. + if (order_of(sg_search) <= order_of(alt)) { + if (order_of(alt) > order_of(sg_search)) + logger.Info("Space-group search: all-observation merge supports {} where the " + "Lorentz-filtered one supports {} - taking the higher symmetry", + alt.point_group_hm, sg_search.point_group_hm); + sg_search = alt; + sm = std::move(sm_all); + } else { + logger.Info("Space-group search: Lorentz-filtered merge supports {} where the " + "all-observation one supports {} - taking the higher symmetry", + sg_search.point_group_hm, alt.point_group_hm); + } + if (order_of(alt) > 0 && order_of(alt) == order_of(sg_search) + && alt.point_group_hm != sg_search.point_group_hm) { + // Same order, different symmetry: the two merges disagree about WHICH operators are + // real, and neither is higher, so there is nothing to prefer. Say so instead of + // picking silently - the two imply different molecular-replacement searches, and + // trying both is cheap next to processing the data again. + const std::string a = alt.best_space_group.has_value() + ? alt.best_space_group->short_name() : alt.point_group_hm; + const std::string b = sg_search.best_space_group.has_value() + ? sg_search.best_space_group->short_name() : sg_search.point_group_hm; + const std::string msg = fmt::format( + "Space group is AMBIGUOUS between {} and {} (point groups {} and {}, the same " + "order): the merge of all observations and the merge of only the well-measured " + "ones each support one of them, and neither is the higher symmetry, so the data " + "do not decide. Processing continues in {} - try BOTH in molecular replacement, " + "or re-run with --search-min-zeta 0 to force the all-observation choice.", + a, b, alt.point_group_hm, sg_search.point_group_hm, a); + logger.Warning("{}", msg); + stats_text << " !! " << msg << "\n\n"; + } + } + // Twinning evidence measured BEFORE any promotion, on the P1/subgroup merge the search was // given. Reported alongside the post-adoption analysis: once a higher Laue class has been // adopted, its own twinning test can only say "no twin law exists within this class", which -- 2.54.0 From 40acf1ffc4346e705910d5d84260c3debc73984c Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 21:56:36 +0200 Subject: [PATCH 052/295] rugnux: search the space group twice by default on rotation data --search-min-zeta now defaults to 0.85 for rotation, so the de-novo search runs on a merge of all the observations AND on a merge of only the well-measured ones, and keeps whichever found more symmetry. Previously it shipped off and the second opinion had to be asked for. Rotation battery, 33 crystals, NO flags beyond the resolution limit: fixed-threshold finder 30/33 - zero crystals differ from the single search adaptive finder 30/33 - the same three mismatches Both arms now agree crystal for crystal, which they have not done before. The last disagreement was a thaumatin set whose 4-fold sits 88.9 deg from the spindle: at defaults it now reads P42(1)2 (all-observation merge -> 222, Lorentz-filtered -> 422, higher taken) where it read P222. The classic arm is a strict no-op - zero differences against both the explicitly-flagged run and the run predating the dual search - so the default costs nothing where the geometry is not the problem, and 47.2 s against 47.8 s on the same crystal back to back. The default is safe to set because the two searches can only disagree by a LOST operator: discarding observations starves an operator correlation, it cannot invent one. That also makes the 0.85 itself uncritical - too aggressive a cut only means the second opinion contributes nothing and the full merge wins. --search-min-zeta 0 restores the single search. Docs: CHANGELOG gains a 1.0.0-rc.161 section covering the branch, and CPU_DATA_ANALYSIS records the four analysis changes of this work - the confidence-weighted per-image refinement, the collapsed per-frame scale guard, the opt-in per-image rejection, and the operator-disagreement criterion with the two-search rule. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CHANGELOG.md | 19 +++++++++++++++++++ docs/CPU_DATA_ANALYSIS.md | 9 +++++++-- rugnux/rugnux_cli.cpp | 11 +++++++---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 438d1723..b19dd0db 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog ## 1.0.0 +### 1.0.0-rc.161 +This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. + +* Spot finding: Self-calibrating **adaptive detection** added - no per-dataset threshold tuning. +* Spot finding: Fused GPU engine (azimuthal integration + detection in one image pass). +* rugnux: Adaptive detection is now the **default** for rotation data as well as stills. +* rugnux: Serial stills pick `--min-pix-per-spot` per image. +* rugnux: Stills **partiality post-refinement** added, on by default (`--simple-stills` disables). +* rugnux: De-novo **space-group search** made substantially more robust. +* rugnux: Rotation **scaling** hardened against a collapsed per-frame scale. +* rugnux: Per-image **geometry refinement** weights spots by confidence. +* rugnux: `--min-image-cc` now works for rotation data (opt-in). +* rugnux: New `--search-min-zeta` (rotation default 0.85). +* rugnux: Reports how close a symmetry axis lies to the spindle. +* rugnux: Azimuthal-integration and spot-finding resolution limits default to the detector. +* rugnux: Several non-helping stills scaling/detection knobs removed. +* Reader: Stored image **bit depth** taken from the file, not the detector default. +* jfjoch_viewer: Image rendering and interaction performance improved. + ### 1.0.0-rc.160 This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index 0bae209b..b5f4903f 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -187,7 +187,7 @@ Special cases: ### 3.2 Adaptive (self-calibrating) detection -The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. The **adaptive** mode (`--adaptive-spots`; the default in `rugnux` and in the viewer, `--no-adaptive-spots` reverts) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. It is the default for rotation data as well: it was held back while a 33-crystal battery showed it breaking three crystals (a lost space group, a halved indexing rate, a collapsed merge), but none of those causes lay in detection. Adaptive finds roughly twice as many spots and they are real — 64 % recur at the same position on the adjacent frame against a 0.5 % chance rate, and none would fail a conventional local-SNR test — but they include genuine peaks that belong to no indexed lattice, and the damage they did scaled with their absolute *count*. Guarding the per-frame scale against collapse and weighting each spot by how strong it is for its resolution (§ per-image refinement) removed all three failures; adaptive now matches the exact space group on 26 of 33 crystals against 25 for the fixed-threshold finder, with better ISa on six. +The local-statistics test above still needs a fixed photon/count threshold, and the right value depends on the background level, which varies between datasets — so it has to be tuned per dataset. The **adaptive** mode (`--adaptive-spots`; the default in `rugnux` and in the viewer, `--no-adaptive-spots` reverts) removes that tuning by deriving the threshold from each image's own noise, per resolution ring. It is the default for rotation data as well. Adaptive finds roughly twice as many spots on rotation data and they are genuine peaks — but they include real reflections belonging to no indexed lattice, and their effect on the per-image geometry fit scales with their absolute *count*, which is why they are down-weighted rather than filtered (§7.4). Pixels are binned into the same resolution rings as the azimuthal integrator (§2). For each ring a robust background is estimated in three passes: one plain pass over all valid pixels, then two $\sigma$-clipping passes that keep only pixels within $\pm 3\sigma$ of the current ring mean (removing the Bragg peaks from the background estimate). This yields a per-ring background mean $\mu_b$ and scatter $\sigma_b$. @@ -376,6 +376,8 @@ with $R(\phi)$ constructed from the axis-angle representation of the goniometer Refinement is performed in stages with decreasing acceptance tolerance for including reflections (three stages, indexing tolerance $0.3\to0.2\to0.1$), which stabilizes convergence when starting from imperfect indexing and approximate geometry. +The loose first stage necessarily admits some spots that are not reflections of this lattice — the fraction of *randomly* placed spots inside a fractional-Miller tolerance $t$ is $\tfrac{4}{3}\pi t^3$, i.e. 11 % at $t=0.3$ — and an unweighted fit lets them pull the orientation. Each residual is therefore weighted by how strong its spot is **for its resolution**: the frame's spots are cut into equal-count resolution shells and each intensity is divided by its shell median, mapped to $w^2=r/(1+r)$. The shell normalisation is what makes this safe — genuine high-resolution spots are legitimately weaker and carry the cell and distance information, so an un-normalised intensity weight would suppress exactly the spots the fit needs. Because the weight is a property of the spot and never of the current residual, it cannot mistake a genuine spot for an outlier while the geometry is still far off, which is the failure mode of a robust loss here. + ### 7.5 Rotation geometry post-refinement (two-pass) The refinement above (§7.2) runs per image against that image's spots. For rotation data an additional **post-refinement** (on by default; `--rotation-no-postrefine` disables it) improves the detector distance, beam centre and crystal cell/axis using **all** frames at once, then re-integrates: @@ -556,6 +558,8 @@ Reflections below a minimum partiality can be rejected from merging to avoid uns The per-frame scales $G_i$ are fit by robust (Cauchy) inverse-variance-weighted ratios; there is no explicit $G\approx1$ prior. For rotation datasets, optional smoothing enforces the expectation that scale and mosaicity vary slowly across a sweep: **after** the per-frame fit, $\log G_i$ (and the mosaicity) are replaced by a centred **moving average** over a window spanning a configurable rotation range (XDS DELPHI-like; `--smooth-g`, default 5° for rot3d, off otherwise). It is a post-fit smoothing pass, not a curvature penalty inside the least-squares objective. +A per-frame scale enters every intensity as $1/G$, so a frame whose fit is not determined by its data can amplify it without bound — and $\sigma$ is amplified by the same factor, which makes it invisible to any $\sigma$-based outlier test. A fitted $G$ far below the run's median is therefore treated as *undetermined* rather than as a successful fit, both here and in the separate refit on the combined fulls (§10.6). The bound is a ratio to the run's own median because $G$ is not gauge-fixed: it and the merged means have an exact global multiplicative degeneracy, so no absolute value is meaningful. + ### 10.4 Merging estimator After refinement, corrected observations are formed: @@ -596,6 +600,7 @@ The combine groups each reflection's partials into rocking events (contiguous ru - **De-biased weighted sum.** Partials are combined by inverse-variance weighting, where each partial's variance is its background-noise component plus the *model* signal shared across the event (Kabsch profile-fit form). Using the shared model signal rather than the individual down-fluctuating intensity stops weak partials from being over-weighted, which would otherwise inflate the merged error model. The weights depend on the full, so the estimate is iterated. - **Captured fraction.** The partiality summed over the event, $f=\min(1,\sum_j p_j)$, measures how completely the rocking curve was sampled. A full whose curve was captured below a threshold (`--min-captured-fraction`, default 0.7 for rotation) is dropped — an event seen over only a small fraction of its curve is unreliable however many frames it spans. (The per-partial minimum-partiality cut of §10.2 still applies upstream, in the per-frame scaling.) +- **Per-image rejection (opt-in).** A frame whose observations correlate poorly with the merged reference is not measuring the crystal being merged — it may be off-crystal, or on a *different* crystal where two lattices occupy separate regions of the sample. `--min-image-cc` drops such frames. It has no default: the per-frame correlation is as much a measure of data quality as of frame validity, and its median ranges from 0.30 to 0.81 across ordinary datasets, so one absolute bound removes a handful of frames from one and a third of another. - **Capture-aware uncertainty.** A full captured incompletely ($f<1$) is extrapolated and biased high. The unobserved fraction is charged as an extra systematic uncertainty, $\sigma^2 \leftarrow \sigma^2 + \big(c\,(1-f)\,I\big)^2$, so the merge down-weights these extrapolated fulls and the error model treats their scatter as expected. It is enabled by default for the rotation path. The fulls are then re-scaled in the XDS sense — a per-image scale refit directly on the complete reflections under the unity partiality model — and merged (§10.4). Because every merged observation is now a counting-statistics-limited full rather than a partiality-divided slice, the error model reaches a far higher asymptotic $I/\sigma$. @@ -700,7 +705,7 @@ A **dataset-wide** Wilson $B$ is also estimated over the merged reflections — - **Space-group symmetry** beyond centering absences is not necessarily enforced during prediction/integration unless the space group is supplied and used downstream. - **Resolution masking and ice rings** are controllable; including ice-ring spots in indexing can improve robustness for some samples but may bias refinement in others. - **Rotation vs still modes** differ substantially in prediction and scaling: partiality is angle-driven in rotation data, while stills are predicted within an excitation-error window and get their partiality from the default-on per-crystal tilt post-refinement (§10.2) — or unit partiality with `--simple-stills`. -- **Space-group determination.** When no space group is supplied, a POINTLESS-like search scores Laue-group symmetry (CC of $I(h)$ vs $I(Rh)$ plus merge self-consistency) and detects screw/centering absences from the $P1$-merged intensities. The self-consistency test is calibrated so a merohedral twin — whose twin law forces non-equivalent reflections together and inflates the merged $\chi^2$ — stays in its true lower symmetry rather than being over-promoted to the holohedral group. Because a partial twin's within-orbit $\chi^2$ can nonetheless look self-consistent, a chi²-passing promotion is additionally **vetoed** when merging its extra operator balloons the error-model $b$ (the intensity-proportional systematic) relative to the confirmed subgroup: a genuine symmetry step gains multiplicity without inflating $b$, whereas a twin forces non-equivalent reflections together and $b$ balloons. **Centering** is accepted when the systematically-absent class is weak relative to the present one by *either* of two floor-independent tests — its mean signed $I/\sigma$ well below the present mean, *or* its rate of individually-significant reflections well below the present class's own significant rate. The second test matters on weak / low-energy data, where a positive intensity floor (background/profile leakage) lifts the absent class's mean $I/\sigma$ to $\sim1.5$–$2.3$ instead of $\sim0$ and, when the present class is itself weak, inflates the plain mean ratio past its bound and hides a real centering (an $I$-centred cubic recorded at 5 keV was otherwise kept primitive); a false centering fails both tests because its absent class is as strong as the present one. When several centerings pass, they are ranked by their **net** systematic absences (absent minus violating), not the gross absent count, so a super-centering (e.g. $F$ over a true $C$) whose extra, only-half-populated absent class merely dilutes the strength ratio does not out-rank the correct lower centering. +- **Space-group determination.** When no space group is supplied, a POINTLESS-like search scores Laue-group symmetry (CC of $I(h)$ vs $I(Rh)$ plus merge self-consistency) and detects screw/centering absences from the $P1$-merged intensities. The self-consistency test is calibrated so a merohedral twin — whose twin law forces non-equivalent reflections together and inflates the merged $\chi^2$ — stays in its true lower symmetry rather than being over-promoted to the holohedral group. Because a partial twin's within-orbit $\chi^2$ can nonetheless look self-consistent, a chi²-passing promotion is additionally **vetoed** when merging its extra operator balloons the error-model $b$ (the intensity-proportional systematic) relative to the confirmed subgroup: a genuine symmetry step gains multiplicity without inflating $b$, whereas a twin forces non-equivalent reflections together and $b$ balloons. A $\chi^2$ test alone cannot decide this, because it is a ratio to an error model that moves with the *amount* of data: the parent's systematic term grows as $\sigma$ shrinks with $1/\sqrt{N}$ while a twin's is already saturated, so the same crystal is promoted or not depending only on how much of it the search saw. The promotion is therefore also gated on a sigma-free **operator disagreement** $H=\mathrm{median}\,|I_1-I_2|/(I_1+I_2)$, taken as the ratio of the operators a promotion *adds* to the parent's own, measured on the same reflections; the parent normalisation divides out the systematic floor that symmetry mates always carry on real data, which varies by crystal *and* by operator, so no absolute bound on such a statistic can work. A median, because a twin perturbs every pair while a badly-measured minority perturbs only the tail; and judged against the most damning parent when a candidate has several of the same order, since a rival subgroup can itself contain the twin laws. The Lorentz factor $\zeta$ (§8.3) governs how well a reflection can be measured, so when the spindle lies in a plane of the lattice an operator permuting the two in-plane axes samples a different mixture of measurement qualities than one that only flips signs. The search is therefore repeated on a merge of only the well-measured observations (`--search-min-zeta`, rotation default 0.85) and **whichever search found more symmetry is kept** — one-way safe, because discarding observations can starve an operator correlation but never invent one. The filtered merge decides the point group only; absences always come from the full merge, since they live in the weak reflections the filter removes. An unbreakable tie (same order, different symmetry) is reported with both candidates named, for trying in molecular replacement. **Centering** is accepted when the systematically-absent class is weak relative to the present one by *either* of two floor-independent tests — its mean signed $I/\sigma$ well below the present mean, *or* its rate of individually-significant reflections well below the present class's own significant rate. The second test matters on weak / low-energy data, where a positive intensity floor (background/profile leakage) lifts the absent class's mean $I/\sigma$ to $\sim1.5$–$2.3$ instead of $\sim0$ and, when the present class is itself weak, inflates the plain mean ratio past its bound and hides a real centering (an $I$-centred cubic recorded at 5 keV was otherwise kept primitive); a false centering fails both tests because its absent class is as strong as the present one. When several centerings pass, they are ranked by their **net** systematic absences (absent minus violating), not the gross absent count, so a super-centering (e.g. $F$ over a true $C$) whose extra, only-half-populated absent class merely dilutes the strength ratio does not out-rank the correct lower centering. - **Twinning check.** A Padilla–Yeates $L$-test ($\langle|L|\rangle$, $\langle L^2\rangle$) and the second moment $\langle I^2\rangle/\langle I\rangle^2$ (taken per resolution shell with noise-only shells skipped and Wilson outliers rejected, so a single strong reflection in a collapsed-mean shell cannot skew it) are written to the merged mmCIF as a twinning diagnostic. Twinning is only flagged in Laue classes where a merohedral twin law can exist; the holohedral high-symmetry classes ($4/mmm$, $6/mmm$, $m\bar{3}m$, and $\bar{3}m$ on a rhombohedral lattice) are exempt, so a low $\langle|L|\rangle$ there is reported as a statistical artefact rather than twinning. - **Outlier rejection.** Merging applies an optional per-observation median-based $N\sigma$ cut (`--reject-outliers`, default 6σ for `rot3d`, off otherwise). The same $N\sigma$ cut is fed back into the error model: after an initial $a,b$ fit the parameters are re-fit once on the reflections that survive rejection (dropping any whose squared deviation exceeds $N\sigma^2\,[a\,\sigma^2 + (b\,\langle I\rangle)^2]$), so the calibrated errors describe the reflections that actually enter the merge rather than the pre-rejection pool. - **Automatic resolution cutoff.** By default the reported/written high-resolution limit is trimmed where $\mathrm{CC}_{1/2}$ falls off (logistic, target 0.30); `--scaling-high-resolution` overrides it and `--resolution-cutoff off` disables it. diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 30388b68..bfdc3224 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -109,7 +109,7 @@ void print_usage() { std::cout << " --min-captured-fraction rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl; std::cout << " --mosaicity Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl; std::cout << " --reject-outliers Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl; - std::cout << " --search-min-zeta De-novo space-group search only: ignore observations whose Lorentz geometry |zeta| is below this (0-1). Reflections crossing the Ewald sphere near-tangentially are measured badly and can make a symmetry operator look wrong (default: 0 = use all)" << std::endl; + std::cout << " --search-min-zeta De-novo space-group search only: also search a merge of just the observations whose Lorentz geometry |zeta| reaches this, and keep whichever search found MORE symmetry (default: 0.85 for rotation, 0 = single search). Reflections crossing the Ewald sphere near-tangentially are measured worst and can make a real symmetry operator look like a twin law" << std::endl; std::cout << " --min-image-cc Per-image CC limit in percent (default: no limit)" << std::endl; std::cout << " --scaling-iterations Number of scaling iterations with no reference data (default: 3)" << std::endl; std::cout << " -z, --reference-mtz Reference MTZ file" << std::endl; @@ -530,7 +530,7 @@ int main(int argc, char **argv) { std::optional capture_uncertainty_arg; // explicit --capture-uncertainty; default depends on rot3d std::optional forced_mosaicity_arg; // diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed double min_image_cc = 0.0; - double search_min_zeta = 0.0; // --search-min-zeta; de-novo search merge only + std::optional search_min_zeta_arg; // --search-min-zeta; rotation default below int64_t scaling_iter = 3; std::optional forced_rotation_lattice; std::optional refine_geometry; // --refine-geometry[=N]: stills global geometry-refinement pass @@ -856,7 +856,7 @@ int main(int argc, char **argv) { min_image_cc = parse_double_arg(optarg, "--min-image-cc", logger); break; case OPT_SEARCH_MIN_ZETA: - search_min_zeta = parse_double_arg(optarg, "--search-min-zeta", logger); + search_min_zeta_arg = parse_double_arg(optarg, "--search-min-zeta", logger); break; case OPT_SCALING_HIGH_RESOLUTION: d_min_scale_merge = atof(optarg); @@ -1432,7 +1432,10 @@ int main(int argc, char **argv) { scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(rotation_indexing ? 1.0 : 0.0)); scaling_settings.ForcedMosaicity(forced_mosaicity_arg); scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction - scaling_settings.SearchMinZeta(search_min_zeta); + // Rotation default: run the de-novo space-group search a second time on a merge of only the + // well-measured observations and keep whichever found more symmetry. It cannot lose symmetry - see + // Rugnux.cpp - so the cut being imperfect only means the second opinion contributes nothing. + scaling_settings.SearchMinZeta(search_min_zeta_arg.value_or(rotation_indexing ? 0.85 : 0.0)); scaling_settings.OutlierRejectNsigma( outlier_reject_nsigma.value_or(rotation_indexing ? REJECT_OUTLIERS_DEFAULT_NSIGMA : 0.0)); -- 2.54.0 From 43e9de957306e2f230ec4bbc85277b72c71bc3f4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 28 Jul 2026 21:58:00 +0200 Subject: [PATCH 053/295] VERSION: 1.0.0-rc.161 --- VERSION | 2 +- broker/gen/model/Azim_int_settings.cpp | 2 +- broker/gen/model/Azim_int_settings.h | 2 +- .../gen/model/Bragg_integration_settings.cpp | 2 +- broker/gen/model/Bragg_integration_settings.h | 2 +- broker/gen/model/Broker_status.cpp | 2 +- broker/gen/model/Broker_status.h | 2 +- .../model/Calibration_statistics_inner.cpp | 2 +- .../gen/model/Calibration_statistics_inner.h | 2 +- broker/gen/model/Dark_mask_settings.cpp | 2 +- broker/gen/model/Dark_mask_settings.h | 2 +- broker/gen/model/Dataset_settings.cpp | 2 +- broker/gen/model/Dataset_settings.h | 2 +- broker/gen/model/Dataset_settings_smargon.cpp | 2 +- broker/gen/model/Dataset_settings_smargon.h | 2 +- ...et_settings_xray_fluorescence_spectrum.cpp | 2 +- ...aset_settings_xray_fluorescence_spectrum.h | 2 +- broker/gen/model/Detector.cpp | 2 +- broker/gen/model/Detector.h | 2 +- broker/gen/model/Detector_list.cpp | 2 +- broker/gen/model/Detector_list.h | 2 +- broker/gen/model/Detector_list_element.cpp | 2 +- broker/gen/model/Detector_list_element.h | 2 +- broker/gen/model/Detector_module.cpp | 2 +- broker/gen/model/Detector_module.h | 2 +- .../gen/model/Detector_module_direction.cpp | 2 +- broker/gen/model/Detector_module_direction.h | 2 +- broker/gen/model/Detector_power_state.cpp | 2 +- broker/gen/model/Detector_power_state.h | 2 +- broker/gen/model/Detector_selection.cpp | 2 +- broker/gen/model/Detector_selection.h | 2 +- broker/gen/model/Detector_settings.cpp | 2 +- broker/gen/model/Detector_settings.h | 2 +- broker/gen/model/Detector_state.cpp | 2 +- broker/gen/model/Detector_state.h | 2 +- broker/gen/model/Detector_status.cpp | 2 +- broker/gen/model/Detector_status.h | 2 +- broker/gen/model/Detector_timing.cpp | 2 +- broker/gen/model/Detector_timing.h | 2 +- broker/gen/model/Detector_type.cpp | 2 +- broker/gen/model/Detector_type.h | 2 +- broker/gen/model/Error_message.cpp | 2 +- broker/gen/model/Error_message.h | 2 +- broker/gen/model/File_writer_format.cpp | 2 +- broker/gen/model/File_writer_format.h | 2 +- broker/gen/model/File_writer_settings.cpp | 2 +- broker/gen/model/File_writer_settings.h | 2 +- broker/gen/model/Fpga_status_inner.cpp | 2 +- broker/gen/model/Fpga_status_inner.h | 2 +- .../gen/model/Geom_refinement_algorithm.cpp | 2 +- broker/gen/model/Geom_refinement_algorithm.h | 2 +- broker/gen/model/Grid_scan.cpp | 2 +- broker/gen/model/Grid_scan.h | 2 +- broker/gen/model/Helpers.cpp | 2 +- broker/gen/model/Helpers.h | 2 +- broker/gen/model/Image_buffer_status.cpp | 2 +- broker/gen/model/Image_buffer_status.h | 2 +- broker/gen/model/Image_format_settings.cpp | 2 +- broker/gen/model/Image_format_settings.h | 2 +- broker/gen/model/Image_pusher_status.cpp | 2 +- broker/gen/model/Image_pusher_status.h | 2 +- broker/gen/model/Image_pusher_type.cpp | 2 +- broker/gen/model/Image_pusher_type.h | 2 +- broker/gen/model/Indexing_algorithm.cpp | 2 +- broker/gen/model/Indexing_algorithm.h | 2 +- broker/gen/model/Indexing_settings.cpp | 2 +- broker/gen/model/Indexing_settings.h | 2 +- broker/gen/model/Instrument_metadata.cpp | 2 +- broker/gen/model/Instrument_metadata.h | 2 +- broker/gen/model/Integration_model.cpp | 2 +- broker/gen/model/Integration_model.h | 2 +- broker/gen/model/Jfjoch_settings.cpp | 2 +- broker/gen/model/Jfjoch_settings.h | 2 +- broker/gen/model/Jfjoch_settings_ssl.cpp | 133 ------------------ broker/gen/model/Jfjoch_settings_ssl.h | 84 ----------- broker/gen/model/Jfjoch_statistics.cpp | 2 +- broker/gen/model/Jfjoch_statistics.h | 2 +- broker/gen/model/Measurement_statistics.cpp | 2 +- broker/gen/model/Measurement_statistics.h | 2 +- broker/gen/model/Pcie_devices_inner.cpp | 2 +- broker/gen/model/Pcie_devices_inner.h | 2 +- broker/gen/model/Pixel_mask_statistics.cpp | 2 +- broker/gen/model/Pixel_mask_statistics.h | 2 +- broker/gen/model/Plot.cpp | 2 +- broker/gen/model/Plot.h | 2 +- broker/gen/model/Plot_unit_x.cpp | 2 +- broker/gen/model/Plot_unit_x.h | 2 +- broker/gen/model/Plots.cpp | 2 +- broker/gen/model/Plots.h | 2 +- broker/gen/model/Roi_azim_list.cpp | 2 +- broker/gen/model/Roi_azim_list.h | 2 +- broker/gen/model/Roi_azimuthal.cpp | 2 +- broker/gen/model/Roi_azimuthal.h | 2 +- broker/gen/model/Roi_box.cpp | 2 +- broker/gen/model/Roi_box.h | 2 +- broker/gen/model/Roi_box_list.cpp | 2 +- broker/gen/model/Roi_box_list.h | 2 +- broker/gen/model/Roi_circle.cpp | 2 +- broker/gen/model/Roi_circle.h | 2 +- broker/gen/model/Roi_circle_list.cpp | 2 +- broker/gen/model/Roi_circle_list.h | 2 +- broker/gen/model/Roi_definitions.cpp | 2 +- broker/gen/model/Roi_definitions.h | 2 +- broker/gen/model/Rotation_axis.cpp | 2 +- broker/gen/model/Rotation_axis.h | 2 +- broker/gen/model/Scan_result.cpp | 2 +- broker/gen/model/Scan_result.h | 2 +- broker/gen/model/Scan_result_images_inner.cpp | 2 +- broker/gen/model/Scan_result_images_inner.h | 2 +- broker/gen/model/Spot_finding_settings.cpp | 2 +- broker/gen/model/Spot_finding_settings.h | 2 +- .../gen/model/Standard_detector_geometry.cpp | 2 +- broker/gen/model/Standard_detector_geometry.h | 2 +- broker/gen/model/Tcp_settings.cpp | 2 +- broker/gen/model/Tcp_settings.h | 2 +- broker/gen/model/Unit_cell.cpp | 2 +- broker/gen/model/Unit_cell.h | 2 +- broker/gen/model/Zeromq_metadata_settings.cpp | 2 +- broker/gen/model/Zeromq_metadata_settings.h | 2 +- broker/gen/model/Zeromq_preview_settings.cpp | 2 +- broker/gen/model/Zeromq_preview_settings.h | 2 +- broker/gen/model/Zeromq_settings.cpp | 2 +- broker/gen/model/Zeromq_settings.h | 2 +- broker/jfjoch_api.yaml | 2 +- broker/redoc-static.html | 16 ++- docs/conf.py | 2 +- docs/python_client/README.md | 4 +- docs/python_client/docs/AzimIntSettings.md | 2 +- .../docs/BraggIntegrationSettings.md | 30 ++++ docs/python_client/docs/ColorScale.md | 16 --- .../docs/GridScanResultImagesInner.md | 36 ----- docs/python_client/docs/IntegrationModel.md | 15 ++ docs/python_client/docs/JfjochBrokerApi.md | 77 ---------- .../python_client/docs/SpotFindingSettings.md | 2 +- fpga/hdl/action_config.v | 2 +- fpga/pcie_driver/dkms.conf | 2 +- fpga/pcie_driver/install_dkms.sh | 2 +- fpga/pcie_driver/jfjoch_drv.c | 2 +- fpga/pcie_driver/postinstall.sh | 2 +- fpga/pcie_driver/preuninstall.sh | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/version.ts | 2 +- 143 files changed, 192 insertions(+), 489 deletions(-) delete mode 100644 broker/gen/model/Jfjoch_settings_ssl.cpp delete mode 100644 broker/gen/model/Jfjoch_settings_ssl.h create mode 100644 docs/python_client/docs/BraggIntegrationSettings.md delete mode 100644 docs/python_client/docs/ColorScale.md delete mode 100644 docs/python_client/docs/GridScanResultImagesInner.md create mode 100644 docs/python_client/docs/IntegrationModel.md delete mode 100644 docs/python_client/docs/JfjochBrokerApi.md diff --git a/VERSION b/VERSION index 5de93a7d..96cb0041 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.160 +1.0.0-rc.161 diff --git a/broker/gen/model/Azim_int_settings.cpp b/broker/gen/model/Azim_int_settings.cpp index 1386b470..33c4bfd8 100644 --- a/broker/gen/model/Azim_int_settings.cpp +++ b/broker/gen/model/Azim_int_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Azim_int_settings.h b/broker/gen/model/Azim_int_settings.h index 51889fd7..81ca100a 100644 --- a/broker/gen/model/Azim_int_settings.h +++ b/broker/gen/model/Azim_int_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Bragg_integration_settings.cpp b/broker/gen/model/Bragg_integration_settings.cpp index 0846db68..8b1328bf 100644 --- a/broker/gen/model/Bragg_integration_settings.cpp +++ b/broker/gen/model/Bragg_integration_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Bragg_integration_settings.h b/broker/gen/model/Bragg_integration_settings.h index 872f67d3..b82dc5c0 100644 --- a/broker/gen/model/Bragg_integration_settings.h +++ b/broker/gen/model/Bragg_integration_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Broker_status.cpp b/broker/gen/model/Broker_status.cpp index 98a03a09..d8812516 100644 --- a/broker/gen/model/Broker_status.cpp +++ b/broker/gen/model/Broker_status.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Broker_status.h b/broker/gen/model/Broker_status.h index a02579e3..f6019095 100644 --- a/broker/gen/model/Broker_status.h +++ b/broker/gen/model/Broker_status.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Calibration_statistics_inner.cpp b/broker/gen/model/Calibration_statistics_inner.cpp index fa8f60f8..a7bce72f 100644 --- a/broker/gen/model/Calibration_statistics_inner.cpp +++ b/broker/gen/model/Calibration_statistics_inner.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Calibration_statistics_inner.h b/broker/gen/model/Calibration_statistics_inner.h index aad20ba7..bd07ab0d 100644 --- a/broker/gen/model/Calibration_statistics_inner.h +++ b/broker/gen/model/Calibration_statistics_inner.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dark_mask_settings.cpp b/broker/gen/model/Dark_mask_settings.cpp index c86648df..cc5a7f89 100644 --- a/broker/gen/model/Dark_mask_settings.cpp +++ b/broker/gen/model/Dark_mask_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dark_mask_settings.h b/broker/gen/model/Dark_mask_settings.h index fd66c48a..d2689aba 100644 --- a/broker/gen/model/Dark_mask_settings.h +++ b/broker/gen/model/Dark_mask_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings.cpp b/broker/gen/model/Dataset_settings.cpp index a27e26ab..2c092598 100644 --- a/broker/gen/model/Dataset_settings.cpp +++ b/broker/gen/model/Dataset_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings.h b/broker/gen/model/Dataset_settings.h index 697578a8..776c95b6 100644 --- a/broker/gen/model/Dataset_settings.h +++ b/broker/gen/model/Dataset_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings_smargon.cpp b/broker/gen/model/Dataset_settings_smargon.cpp index 7585e7ff..d4048618 100644 --- a/broker/gen/model/Dataset_settings_smargon.cpp +++ b/broker/gen/model/Dataset_settings_smargon.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings_smargon.h b/broker/gen/model/Dataset_settings_smargon.h index 595ec63c..7260bc22 100644 --- a/broker/gen/model/Dataset_settings_smargon.h +++ b/broker/gen/model/Dataset_settings_smargon.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.cpp b/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.cpp index 4331061e..8cf76df5 100644 --- a/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.cpp +++ b/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.h b/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.h index 8903ef06..36b96166 100644 --- a/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.h +++ b/broker/gen/model/Dataset_settings_xray_fluorescence_spectrum.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector.cpp b/broker/gen/model/Detector.cpp index 20e9ef0f..df92a37f 100644 --- a/broker/gen/model/Detector.cpp +++ b/broker/gen/model/Detector.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector.h b/broker/gen/model/Detector.h index a64d153d..a94b5ae4 100644 --- a/broker/gen/model/Detector.h +++ b/broker/gen/model/Detector.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_list.cpp b/broker/gen/model/Detector_list.cpp index 5444fd4a..497e92a1 100644 --- a/broker/gen/model/Detector_list.cpp +++ b/broker/gen/model/Detector_list.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_list.h b/broker/gen/model/Detector_list.h index 01474c63..93fc5961 100644 --- a/broker/gen/model/Detector_list.h +++ b/broker/gen/model/Detector_list.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_list_element.cpp b/broker/gen/model/Detector_list_element.cpp index fc0fcbea..8d913503 100644 --- a/broker/gen/model/Detector_list_element.cpp +++ b/broker/gen/model/Detector_list_element.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_list_element.h b/broker/gen/model/Detector_list_element.h index 1f7b27d7..d9e9c8bb 100644 --- a/broker/gen/model/Detector_list_element.h +++ b/broker/gen/model/Detector_list_element.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_module.cpp b/broker/gen/model/Detector_module.cpp index 55332d2b..1ce88819 100644 --- a/broker/gen/model/Detector_module.cpp +++ b/broker/gen/model/Detector_module.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_module.h b/broker/gen/model/Detector_module.h index 88037671..91792c96 100644 --- a/broker/gen/model/Detector_module.h +++ b/broker/gen/model/Detector_module.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_module_direction.cpp b/broker/gen/model/Detector_module_direction.cpp index 193e8ed2..54e196d0 100644 --- a/broker/gen/model/Detector_module_direction.cpp +++ b/broker/gen/model/Detector_module_direction.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_module_direction.h b/broker/gen/model/Detector_module_direction.h index 9a2a62c5..63f80ec4 100644 --- a/broker/gen/model/Detector_module_direction.h +++ b/broker/gen/model/Detector_module_direction.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_power_state.cpp b/broker/gen/model/Detector_power_state.cpp index dbcc297a..0ef798fb 100644 --- a/broker/gen/model/Detector_power_state.cpp +++ b/broker/gen/model/Detector_power_state.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_power_state.h b/broker/gen/model/Detector_power_state.h index 96d3c3ce..ccf50a00 100644 --- a/broker/gen/model/Detector_power_state.h +++ b/broker/gen/model/Detector_power_state.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_selection.cpp b/broker/gen/model/Detector_selection.cpp index e5879ef1..d469b5c0 100644 --- a/broker/gen/model/Detector_selection.cpp +++ b/broker/gen/model/Detector_selection.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_selection.h b/broker/gen/model/Detector_selection.h index 0f02cd0a..7b26de39 100644 --- a/broker/gen/model/Detector_selection.h +++ b/broker/gen/model/Detector_selection.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_settings.cpp b/broker/gen/model/Detector_settings.cpp index 107ff494..83d3ffbc 100644 --- a/broker/gen/model/Detector_settings.cpp +++ b/broker/gen/model/Detector_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_settings.h b/broker/gen/model/Detector_settings.h index 678bf119..2b1e3260 100644 --- a/broker/gen/model/Detector_settings.h +++ b/broker/gen/model/Detector_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_state.cpp b/broker/gen/model/Detector_state.cpp index b317789e..aabc2c07 100644 --- a/broker/gen/model/Detector_state.cpp +++ b/broker/gen/model/Detector_state.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_state.h b/broker/gen/model/Detector_state.h index 514f866e..dcdf016d 100644 --- a/broker/gen/model/Detector_state.h +++ b/broker/gen/model/Detector_state.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_status.cpp b/broker/gen/model/Detector_status.cpp index 9838f5e8..fc77eae3 100644 --- a/broker/gen/model/Detector_status.cpp +++ b/broker/gen/model/Detector_status.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_status.h b/broker/gen/model/Detector_status.h index dcf7e85c..57eb5ddb 100644 --- a/broker/gen/model/Detector_status.h +++ b/broker/gen/model/Detector_status.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_timing.cpp b/broker/gen/model/Detector_timing.cpp index 22cc2ef4..a9bed184 100644 --- a/broker/gen/model/Detector_timing.cpp +++ b/broker/gen/model/Detector_timing.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_timing.h b/broker/gen/model/Detector_timing.h index 734d756f..72bbc208 100644 --- a/broker/gen/model/Detector_timing.h +++ b/broker/gen/model/Detector_timing.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_type.cpp b/broker/gen/model/Detector_type.cpp index f92b4ffd..a326f97c 100644 --- a/broker/gen/model/Detector_type.cpp +++ b/broker/gen/model/Detector_type.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Detector_type.h b/broker/gen/model/Detector_type.h index e84c08aa..987c9be1 100644 --- a/broker/gen/model/Detector_type.h +++ b/broker/gen/model/Detector_type.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Error_message.cpp b/broker/gen/model/Error_message.cpp index f7cecd1e..536b8d93 100644 --- a/broker/gen/model/Error_message.cpp +++ b/broker/gen/model/Error_message.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Error_message.h b/broker/gen/model/Error_message.h index 835b3417..d14040ab 100644 --- a/broker/gen/model/Error_message.h +++ b/broker/gen/model/Error_message.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/File_writer_format.cpp b/broker/gen/model/File_writer_format.cpp index efe1542b..84da9dff 100644 --- a/broker/gen/model/File_writer_format.cpp +++ b/broker/gen/model/File_writer_format.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/File_writer_format.h b/broker/gen/model/File_writer_format.h index fbb71711..178098bd 100644 --- a/broker/gen/model/File_writer_format.h +++ b/broker/gen/model/File_writer_format.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/File_writer_settings.cpp b/broker/gen/model/File_writer_settings.cpp index 2ffd31db..912b27b0 100644 --- a/broker/gen/model/File_writer_settings.cpp +++ b/broker/gen/model/File_writer_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/File_writer_settings.h b/broker/gen/model/File_writer_settings.h index 599c8d85..46ca65fc 100644 --- a/broker/gen/model/File_writer_settings.h +++ b/broker/gen/model/File_writer_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Fpga_status_inner.cpp b/broker/gen/model/Fpga_status_inner.cpp index ce76724f..075c8d68 100644 --- a/broker/gen/model/Fpga_status_inner.cpp +++ b/broker/gen/model/Fpga_status_inner.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Fpga_status_inner.h b/broker/gen/model/Fpga_status_inner.h index 90638011..86e9d390 100644 --- a/broker/gen/model/Fpga_status_inner.h +++ b/broker/gen/model/Fpga_status_inner.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Geom_refinement_algorithm.cpp b/broker/gen/model/Geom_refinement_algorithm.cpp index a7cd91cc..798b7fdc 100644 --- a/broker/gen/model/Geom_refinement_algorithm.cpp +++ b/broker/gen/model/Geom_refinement_algorithm.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Geom_refinement_algorithm.h b/broker/gen/model/Geom_refinement_algorithm.h index bd0737f5..ca435dfc 100644 --- a/broker/gen/model/Geom_refinement_algorithm.h +++ b/broker/gen/model/Geom_refinement_algorithm.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Grid_scan.cpp b/broker/gen/model/Grid_scan.cpp index 45abd6ad..dde6809d 100644 --- a/broker/gen/model/Grid_scan.cpp +++ b/broker/gen/model/Grid_scan.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Grid_scan.h b/broker/gen/model/Grid_scan.h index 4ff4e6d2..95a582ab 100644 --- a/broker/gen/model/Grid_scan.h +++ b/broker/gen/model/Grid_scan.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Helpers.cpp b/broker/gen/model/Helpers.cpp index 70f81992..93970f59 100644 --- a/broker/gen/model/Helpers.cpp +++ b/broker/gen/model/Helpers.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Helpers.h b/broker/gen/model/Helpers.h index 18164c7e..89c90583 100644 --- a/broker/gen/model/Helpers.h +++ b/broker/gen/model/Helpers.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_buffer_status.cpp b/broker/gen/model/Image_buffer_status.cpp index bbbe175e..8134c2ad 100644 --- a/broker/gen/model/Image_buffer_status.cpp +++ b/broker/gen/model/Image_buffer_status.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_buffer_status.h b/broker/gen/model/Image_buffer_status.h index dfb9e89c..3fc534f0 100644 --- a/broker/gen/model/Image_buffer_status.h +++ b/broker/gen/model/Image_buffer_status.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_format_settings.cpp b/broker/gen/model/Image_format_settings.cpp index 5b7c804c..ae980650 100644 --- a/broker/gen/model/Image_format_settings.cpp +++ b/broker/gen/model/Image_format_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_format_settings.h b/broker/gen/model/Image_format_settings.h index 75716c93..b51e2ca2 100644 --- a/broker/gen/model/Image_format_settings.h +++ b/broker/gen/model/Image_format_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_pusher_status.cpp b/broker/gen/model/Image_pusher_status.cpp index 92136de0..719882c7 100644 --- a/broker/gen/model/Image_pusher_status.cpp +++ b/broker/gen/model/Image_pusher_status.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_pusher_status.h b/broker/gen/model/Image_pusher_status.h index e157add6..9c398c9b 100644 --- a/broker/gen/model/Image_pusher_status.h +++ b/broker/gen/model/Image_pusher_status.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_pusher_type.cpp b/broker/gen/model/Image_pusher_type.cpp index 37ff447a..619512eb 100644 --- a/broker/gen/model/Image_pusher_type.cpp +++ b/broker/gen/model/Image_pusher_type.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Image_pusher_type.h b/broker/gen/model/Image_pusher_type.h index f8338fe1..b7cb454e 100644 --- a/broker/gen/model/Image_pusher_type.h +++ b/broker/gen/model/Image_pusher_type.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Indexing_algorithm.cpp b/broker/gen/model/Indexing_algorithm.cpp index c154921d..68d82ddf 100644 --- a/broker/gen/model/Indexing_algorithm.cpp +++ b/broker/gen/model/Indexing_algorithm.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Indexing_algorithm.h b/broker/gen/model/Indexing_algorithm.h index 4a37eff2..19bc93ff 100644 --- a/broker/gen/model/Indexing_algorithm.h +++ b/broker/gen/model/Indexing_algorithm.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Indexing_settings.cpp b/broker/gen/model/Indexing_settings.cpp index 41256647..6181a4c8 100644 --- a/broker/gen/model/Indexing_settings.cpp +++ b/broker/gen/model/Indexing_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Indexing_settings.h b/broker/gen/model/Indexing_settings.h index 7b403af5..c03622ff 100644 --- a/broker/gen/model/Indexing_settings.h +++ b/broker/gen/model/Indexing_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Instrument_metadata.cpp b/broker/gen/model/Instrument_metadata.cpp index dd9cccf2..20e3335c 100644 --- a/broker/gen/model/Instrument_metadata.cpp +++ b/broker/gen/model/Instrument_metadata.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Instrument_metadata.h b/broker/gen/model/Instrument_metadata.h index 828f52d5..21104e10 100644 --- a/broker/gen/model/Instrument_metadata.h +++ b/broker/gen/model/Instrument_metadata.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Integration_model.cpp b/broker/gen/model/Integration_model.cpp index 10be0cfa..970644d2 100644 --- a/broker/gen/model/Integration_model.cpp +++ b/broker/gen/model/Integration_model.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Integration_model.h b/broker/gen/model/Integration_model.h index 54376e45..dd74e99c 100644 --- a/broker/gen/model/Integration_model.h +++ b/broker/gen/model/Integration_model.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Jfjoch_settings.cpp b/broker/gen/model/Jfjoch_settings.cpp index af4229bb..8dfa2be4 100644 --- a/broker/gen/model/Jfjoch_settings.cpp +++ b/broker/gen/model/Jfjoch_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Jfjoch_settings.h b/broker/gen/model/Jfjoch_settings.h index a2cb5d16..fce7512f 100644 --- a/broker/gen/model/Jfjoch_settings.h +++ b/broker/gen/model/Jfjoch_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Jfjoch_settings_ssl.cpp b/broker/gen/model/Jfjoch_settings_ssl.cpp deleted file mode 100644 index fdb29d22..00000000 --- a/broker/gen/model/Jfjoch_settings_ssl.cpp +++ /dev/null @@ -1,133 +0,0 @@ -/** -* Jungfraujoch -* API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. -* -* The version of the OpenAPI document: 1.0.0-rc.133 -* Contact: filip.leonarski@psi.ch -* -* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). -* https://openapi-generator.tech -* Do not edit the class manually. -*/ - - -#include "Jfjoch_settings_ssl.h" -#include "Helpers.h" - -#include - -namespace org::openapitools::server::model -{ - -Jfjoch_settings_ssl::Jfjoch_settings_ssl() -{ - m_Certificate = ""; - m_Key = ""; - -} - -void Jfjoch_settings_ssl::validate() const -{ - std::stringstream msg; - if (!validate(msg)) - { - throw org::openapitools::server::helpers::ValidationException(msg.str()); - } -} - -bool Jfjoch_settings_ssl::validate(std::stringstream& msg) const -{ - return validate(msg, ""); -} - -bool Jfjoch_settings_ssl::validate(std::stringstream& msg, const std::string& pathPrefix) const -{ - bool success = true; - const std::string _pathPrefix = pathPrefix.empty() ? "Jfjoch_settings_ssl" : pathPrefix; - - - - /* Certificate */ { - const std::string& value = m_Certificate; - const std::string currentValuePath = _pathPrefix + ".certificate"; - - - if (value.length() < 1) - { - success = false; - msg << currentValuePath << ": must be at least 1 characters long;"; - } - - } - - - /* Key */ { - const std::string& value = m_Key; - const std::string currentValuePath = _pathPrefix + ".key"; - - - if (value.length() < 1) - { - success = false; - msg << currentValuePath << ": must be at least 1 characters long;"; - } - - } - - return success; -} - -bool Jfjoch_settings_ssl::operator==(const Jfjoch_settings_ssl& rhs) const -{ - return - - - (getCertificate() == rhs.getCertificate()) - && - - (getKey() == rhs.getKey()) - - - ; -} - -bool Jfjoch_settings_ssl::operator!=(const Jfjoch_settings_ssl& rhs) const -{ - return !(*this == rhs); -} - -void to_json(nlohmann::json& j, const Jfjoch_settings_ssl& o) -{ - j = nlohmann::json::object(); - j["certificate"] = o.m_Certificate; - j["key"] = o.m_Key; - -} - -void from_json(const nlohmann::json& j, Jfjoch_settings_ssl& o) -{ - j.at("certificate").get_to(o.m_Certificate); - j.at("key").get_to(o.m_Key); - -} - -std::string Jfjoch_settings_ssl::getCertificate() const -{ - return m_Certificate; -} -void Jfjoch_settings_ssl::setCertificate(std::string const& value) -{ - m_Certificate = value; -} -std::string Jfjoch_settings_ssl::getKey() const -{ - return m_Key; -} -void Jfjoch_settings_ssl::setKey(std::string const& value) -{ - m_Key = value; -} - - -} // namespace org::openapitools::server::model - diff --git a/broker/gen/model/Jfjoch_settings_ssl.h b/broker/gen/model/Jfjoch_settings_ssl.h deleted file mode 100644 index c32d2134..00000000 --- a/broker/gen/model/Jfjoch_settings_ssl.h +++ /dev/null @@ -1,84 +0,0 @@ -/** -* Jungfraujoch -* API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. -* -* The version of the OpenAPI document: 1.0.0-rc.133 -* Contact: filip.leonarski@psi.ch -* -* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). -* https://openapi-generator.tech -* Do not edit the class manually. -*/ -/* - * Jfjoch_settings_ssl.h - * - * - */ - -#ifndef Jfjoch_settings_ssl_H_ -#define Jfjoch_settings_ssl_H_ - - -#include -#include - -namespace org::openapitools::server::model -{ - -/// -/// -/// -class Jfjoch_settings_ssl -{ -public: - Jfjoch_settings_ssl(); - virtual ~Jfjoch_settings_ssl() = default; - - - /// - /// Validate the current data in the model. Throws a ValidationException on failure. - /// - void validate() const; - - /// - /// Validate the current data in the model. Returns false on error and writes an error - /// message into the given stringstream. - /// - bool validate(std::stringstream& msg) const; - - /// - /// Helper overload for validate. Used when one model stores another model and calls it's validate. - /// Not meant to be called outside that case. - /// - bool validate(std::stringstream& msg, const std::string& pathPrefix) const; - - bool operator==(const Jfjoch_settings_ssl& rhs) const; - bool operator!=(const Jfjoch_settings_ssl& rhs) const; - - ///////////////////////////////////////////// - /// Jfjoch_settings_ssl members - - /// - /// - /// - std::string getCertificate() const; - void setCertificate(std::string const& value); - /// - /// - /// - std::string getKey() const; - void setKey(std::string const& value); - - friend void to_json(nlohmann::json& j, const Jfjoch_settings_ssl& o); - friend void from_json(const nlohmann::json& j, Jfjoch_settings_ssl& o); -protected: - std::string m_Certificate; - - std::string m_Key; - - -}; - -} // namespace org::openapitools::server::model - -#endif /* Jfjoch_settings_ssl_H_ */ diff --git a/broker/gen/model/Jfjoch_statistics.cpp b/broker/gen/model/Jfjoch_statistics.cpp index 804bb1a4..c1429296 100644 --- a/broker/gen/model/Jfjoch_statistics.cpp +++ b/broker/gen/model/Jfjoch_statistics.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Jfjoch_statistics.h b/broker/gen/model/Jfjoch_statistics.h index 811013c1..754356d7 100644 --- a/broker/gen/model/Jfjoch_statistics.h +++ b/broker/gen/model/Jfjoch_statistics.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Measurement_statistics.cpp b/broker/gen/model/Measurement_statistics.cpp index 8156d388..62864eb9 100644 --- a/broker/gen/model/Measurement_statistics.cpp +++ b/broker/gen/model/Measurement_statistics.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Measurement_statistics.h b/broker/gen/model/Measurement_statistics.h index d8640369..0d94d2e4 100644 --- a/broker/gen/model/Measurement_statistics.h +++ b/broker/gen/model/Measurement_statistics.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Pcie_devices_inner.cpp b/broker/gen/model/Pcie_devices_inner.cpp index dfc258e2..99fa9af9 100644 --- a/broker/gen/model/Pcie_devices_inner.cpp +++ b/broker/gen/model/Pcie_devices_inner.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Pcie_devices_inner.h b/broker/gen/model/Pcie_devices_inner.h index 6efb5f35..2f1e99fb 100644 --- a/broker/gen/model/Pcie_devices_inner.h +++ b/broker/gen/model/Pcie_devices_inner.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Pixel_mask_statistics.cpp b/broker/gen/model/Pixel_mask_statistics.cpp index 2f315fd6..c03f71d2 100644 --- a/broker/gen/model/Pixel_mask_statistics.cpp +++ b/broker/gen/model/Pixel_mask_statistics.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Pixel_mask_statistics.h b/broker/gen/model/Pixel_mask_statistics.h index 772d00b9..a5e2e12e 100644 --- a/broker/gen/model/Pixel_mask_statistics.h +++ b/broker/gen/model/Pixel_mask_statistics.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plot.cpp b/broker/gen/model/Plot.cpp index e367b045..1f73cb6e 100644 --- a/broker/gen/model/Plot.cpp +++ b/broker/gen/model/Plot.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plot.h b/broker/gen/model/Plot.h index 7648770d..94b13c1b 100644 --- a/broker/gen/model/Plot.h +++ b/broker/gen/model/Plot.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plot_unit_x.cpp b/broker/gen/model/Plot_unit_x.cpp index 04137d06..d4bca6ac 100644 --- a/broker/gen/model/Plot_unit_x.cpp +++ b/broker/gen/model/Plot_unit_x.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plot_unit_x.h b/broker/gen/model/Plot_unit_x.h index 7fd1a32a..e414d5cf 100644 --- a/broker/gen/model/Plot_unit_x.h +++ b/broker/gen/model/Plot_unit_x.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plots.cpp b/broker/gen/model/Plots.cpp index 5005e44d..c538fb89 100644 --- a/broker/gen/model/Plots.cpp +++ b/broker/gen/model/Plots.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Plots.h b/broker/gen/model/Plots.h index d47e05d4..35ed322f 100644 --- a/broker/gen/model/Plots.h +++ b/broker/gen/model/Plots.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_azim_list.cpp b/broker/gen/model/Roi_azim_list.cpp index 2667c5af..ab296458 100644 --- a/broker/gen/model/Roi_azim_list.cpp +++ b/broker/gen/model/Roi_azim_list.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_azim_list.h b/broker/gen/model/Roi_azim_list.h index 4318ee14..e77e502f 100644 --- a/broker/gen/model/Roi_azim_list.h +++ b/broker/gen/model/Roi_azim_list.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_azimuthal.cpp b/broker/gen/model/Roi_azimuthal.cpp index 22a35007..8335deee 100644 --- a/broker/gen/model/Roi_azimuthal.cpp +++ b/broker/gen/model/Roi_azimuthal.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_azimuthal.h b/broker/gen/model/Roi_azimuthal.h index e314c8f1..d991ae47 100644 --- a/broker/gen/model/Roi_azimuthal.h +++ b/broker/gen/model/Roi_azimuthal.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_box.cpp b/broker/gen/model/Roi_box.cpp index 9ffa7c84..7627968b 100644 --- a/broker/gen/model/Roi_box.cpp +++ b/broker/gen/model/Roi_box.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_box.h b/broker/gen/model/Roi_box.h index cb3e3e4b..db31aaa1 100644 --- a/broker/gen/model/Roi_box.h +++ b/broker/gen/model/Roi_box.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_box_list.cpp b/broker/gen/model/Roi_box_list.cpp index dcfc2d95..4af7dad9 100644 --- a/broker/gen/model/Roi_box_list.cpp +++ b/broker/gen/model/Roi_box_list.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_box_list.h b/broker/gen/model/Roi_box_list.h index 01c31dbd..cd490a4f 100644 --- a/broker/gen/model/Roi_box_list.h +++ b/broker/gen/model/Roi_box_list.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_circle.cpp b/broker/gen/model/Roi_circle.cpp index 2e33f597..5e0bf9a4 100644 --- a/broker/gen/model/Roi_circle.cpp +++ b/broker/gen/model/Roi_circle.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_circle.h b/broker/gen/model/Roi_circle.h index 4c8e6adb..78a484a3 100644 --- a/broker/gen/model/Roi_circle.h +++ b/broker/gen/model/Roi_circle.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_circle_list.cpp b/broker/gen/model/Roi_circle_list.cpp index e26fa0cc..9b7eaa77 100644 --- a/broker/gen/model/Roi_circle_list.cpp +++ b/broker/gen/model/Roi_circle_list.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_circle_list.h b/broker/gen/model/Roi_circle_list.h index b9a671c9..8f03e712 100644 --- a/broker/gen/model/Roi_circle_list.h +++ b/broker/gen/model/Roi_circle_list.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_definitions.cpp b/broker/gen/model/Roi_definitions.cpp index 63000a33..44d41c30 100644 --- a/broker/gen/model/Roi_definitions.cpp +++ b/broker/gen/model/Roi_definitions.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Roi_definitions.h b/broker/gen/model/Roi_definitions.h index aa58c648..3424f5b0 100644 --- a/broker/gen/model/Roi_definitions.h +++ b/broker/gen/model/Roi_definitions.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Rotation_axis.cpp b/broker/gen/model/Rotation_axis.cpp index 12745652..324adeb9 100644 --- a/broker/gen/model/Rotation_axis.cpp +++ b/broker/gen/model/Rotation_axis.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Rotation_axis.h b/broker/gen/model/Rotation_axis.h index 39d0f2e0..f54a23f3 100644 --- a/broker/gen/model/Rotation_axis.h +++ b/broker/gen/model/Rotation_axis.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Scan_result.cpp b/broker/gen/model/Scan_result.cpp index 31a779d5..bc77488c 100644 --- a/broker/gen/model/Scan_result.cpp +++ b/broker/gen/model/Scan_result.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Scan_result.h b/broker/gen/model/Scan_result.h index d4b5ce3d..6a50baef 100644 --- a/broker/gen/model/Scan_result.h +++ b/broker/gen/model/Scan_result.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Scan_result_images_inner.cpp b/broker/gen/model/Scan_result_images_inner.cpp index 34a82808..aae91bee 100644 --- a/broker/gen/model/Scan_result_images_inner.cpp +++ b/broker/gen/model/Scan_result_images_inner.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Scan_result_images_inner.h b/broker/gen/model/Scan_result_images_inner.h index c2504619..af0aa612 100644 --- a/broker/gen/model/Scan_result_images_inner.h +++ b/broker/gen/model/Scan_result_images_inner.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Spot_finding_settings.cpp b/broker/gen/model/Spot_finding_settings.cpp index b6cc66b7..e91c7d36 100644 --- a/broker/gen/model/Spot_finding_settings.cpp +++ b/broker/gen/model/Spot_finding_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Spot_finding_settings.h b/broker/gen/model/Spot_finding_settings.h index 6acf3381..c8feb461 100644 --- a/broker/gen/model/Spot_finding_settings.h +++ b/broker/gen/model/Spot_finding_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Standard_detector_geometry.cpp b/broker/gen/model/Standard_detector_geometry.cpp index 346e61b7..5fb36020 100644 --- a/broker/gen/model/Standard_detector_geometry.cpp +++ b/broker/gen/model/Standard_detector_geometry.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Standard_detector_geometry.h b/broker/gen/model/Standard_detector_geometry.h index 31d28f26..aa011b8d 100644 --- a/broker/gen/model/Standard_detector_geometry.h +++ b/broker/gen/model/Standard_detector_geometry.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Tcp_settings.cpp b/broker/gen/model/Tcp_settings.cpp index 8e747cdb..a1226fe3 100644 --- a/broker/gen/model/Tcp_settings.cpp +++ b/broker/gen/model/Tcp_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Tcp_settings.h b/broker/gen/model/Tcp_settings.h index 39bd5b97..1fcee807 100644 --- a/broker/gen/model/Tcp_settings.h +++ b/broker/gen/model/Tcp_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Unit_cell.cpp b/broker/gen/model/Unit_cell.cpp index 103965e3..59ea4399 100644 --- a/broker/gen/model/Unit_cell.cpp +++ b/broker/gen/model/Unit_cell.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Unit_cell.h b/broker/gen/model/Unit_cell.h index e4aeb305..b357be1f 100644 --- a/broker/gen/model/Unit_cell.h +++ b/broker/gen/model/Unit_cell.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_metadata_settings.cpp b/broker/gen/model/Zeromq_metadata_settings.cpp index 253886c8..cd9deaae 100644 --- a/broker/gen/model/Zeromq_metadata_settings.cpp +++ b/broker/gen/model/Zeromq_metadata_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_metadata_settings.h b/broker/gen/model/Zeromq_metadata_settings.h index bb0f175d..f8f58d85 100644 --- a/broker/gen/model/Zeromq_metadata_settings.h +++ b/broker/gen/model/Zeromq_metadata_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_preview_settings.cpp b/broker/gen/model/Zeromq_preview_settings.cpp index c724fbd8..df2cf713 100644 --- a/broker/gen/model/Zeromq_preview_settings.cpp +++ b/broker/gen/model/Zeromq_preview_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_preview_settings.h b/broker/gen/model/Zeromq_preview_settings.h index aa6d6c3a..3b47220a 100644 --- a/broker/gen/model/Zeromq_preview_settings.h +++ b/broker/gen/model/Zeromq_preview_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_settings.cpp b/broker/gen/model/Zeromq_settings.cpp index f5057b50..6570f1cc 100644 --- a/broker/gen/model/Zeromq_settings.cpp +++ b/broker/gen/model/Zeromq_settings.cpp @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/gen/model/Zeromq_settings.h b/broker/gen/model/Zeromq_settings.h index 3a447daa..0dbaca06 100644 --- a/broker/gen/model/Zeromq_settings.h +++ b/broker/gen/model/Zeromq_settings.h @@ -2,7 +2,7 @@ * Jungfraujoch * API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. * -* The version of the OpenAPI document: 1.0.0-rc.160 +* The version of the OpenAPI document: 1.0.0-rc.161 * Contact: filip.leonarski@psi.ch * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/broker/jfjoch_api.yaml b/broker/jfjoch_api.yaml index 4385fe5a..e4c174f3 100644 --- a/broker/jfjoch_api.yaml +++ b/broker/jfjoch_api.yaml @@ -22,7 +22,7 @@ info: requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. - version: 1.0.0-rc.160 + version: 1.0.0-rc.161 contact: name: Filip Leonarski (Paul Scherrer Institute) email: filip.leonarski@psi.ch diff --git a/broker/redoc-static.html b/broker/redoc-static.html index 21003a0b..434419cc 100644 --- a/broker/redoc-static.html +++ b/broker/redoc-static.html @@ -399,7 +399,7 @@ This format doesn't transmit information about X-axis, only values, so it i 55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864 -231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688, -104.0616 -231.873,-231.248 z - " fill="currentColor">

Jungfraujoch (1.0.0-rc.160)

Download OpenAPI specification:

Filip Leonarski (Paul Scherrer Institute): filip.leonarski@psi.ch License: GPL-3.0

API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). + " fill="currentColor">

Jungfraujoch (1.0.0-rc.161)

Download OpenAPI specification:

Filip Leonarski (Paul Scherrer Institute): filip.leonarski@psi.ch License: GPL-3.0

API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates.

License Clarification

While this API definition is licensed under GPL-3.0, the GPL copyleft provisions do not apply @@ -700,7 +700,8 @@ This should be turned off for cases, where detector is operated at room temperat

Request Body schema: application/json
enable
required
boolean
Default: true

Enable spot finding. This is temporary setting, i.e. can be changed anytime during data collection. Even if disabled spot finding information will still be send and written, though always with zero spots.

indexing
required
boolean
Default: true

Enable indexing. This is temporary setting, i.e. can be changed anytime during data collection.

-
signal_to_noise_threshold
required
number <float> >= 0
photon_count_threshold
required
integer <int64> >= 0
min_pix_per_spot
required
integer <int64> >= 1
max_pix_per_spot
required
integer <int64> >= 1
high_resolution_limit
required
number <float>

High resolution limit for spot finding [Angstrom]

+
signal_to_noise_threshold
required
number <float> >= 0
photon_count_threshold
required
integer <int64> >= 0
min_pix_per_spot
required
integer <int64> >= 1
max_pix_per_spot
required
integer <int64> >= 1
high_resolution_limit
number <float>

High resolution limit for spot finding [Angstrom]. Optional: if omitted, spot finding extends +as far as the detector reaches, i.e. the detection is not clipped in resolution.

low_resolution_limit
required
number <float>

Low resolution limit for spot finding [Angstrom]

high_resolution_limit_for_spot_count_low_res
required
number <float> [ 2 .. 8 ]

High resolution threshold to consider spot "low resolution" [Angstrom]

quick_integration
required
boolean
Default: false

Quick integration of Bragg spots in diffraction images. @@ -719,7 +720,10 @@ This option should be turned OFF for small molecule datasets or for crystals wit

http://localhost:5232/config/spot_finding

Response samples

Content type
application/json
{
  • "enable": true,
  • "indexing": true,
  • "signal_to_noise_threshold": 0.1,
  • "photon_count_threshold": 0,
  • "min_pix_per_spot": 1,
  • "max_pix_per_spot": 1,
  • "high_resolution_limit": 0.1,
  • "low_resolution_limit": 0.1,
  • "high_resolution_limit_for_spot_count_low_res": 2,
  • "quick_integration": false,
  • "ice_ring_width_q_recipA": 0.02,
  • "high_res_gap_Q_recipA": 1.5
}

Configure azimuthal integration

Can be done when detector is Inactive or Idle

Request Body schema: application/json
polarization_corr
required
boolean
Default: true

Apply polarization correction for azimuthal integration (polarization factor must be configured in dataset settings)

solid_angle_corr
required
boolean
Default: true

Apply solid angle correction for azimuthal integration

-
high_q_recipA
required
number <float> [ 0.00002 .. 10 ]
low_q_recipA
required
number <float> [ 0.00001 .. 10 ]
q_spacing
required
number <float> >= 0.00001
azimuthal_bins
integer <int64> [ 1 .. 512 ]
Default: 1

Numer of azimuthal (phi) bins; 1 = standard 1D azimuthal integration

+
high_q_recipA
number <float> [ 0.00002 .. 10 ]

Upper q limit of the azimuthal integration [1/Angstrom]. Optional: if omitted, the integration +(and the adaptive spot detection that shares these q bins) extends to the highest q the +detector reaches.

+
low_q_recipA
required
number <float> [ 0.00001 .. 10 ]
q_spacing
required
number <float> >= 0.00001
azimuthal_bins
integer <int64> [ 1 .. 512 ]
Default: 1

Numer of azimuthal (phi) bins; 1 = standard 1D azimuthal integration

force_cpu
boolean
Default: false

Force CPU processing of azimuthal integration in the FPGA data acquisition workflow. This allows to extend number of azimuthal integration bins, as well as to calculate standard deviation of the azimuthal integration results.

@@ -889,7 +893,7 @@ User mask is not automatically applied - i.e. pixels with user mask will have a

Generate 1D plot from Jungfraujoch

query Parameters
binning
integer
Default: 1

Binning of frames for the plot (0 = default binning)

-
type
required
string
Enum: "bkg_estimate" "azint" "azint_1d" "spot_count" "spot_count_low_res" "spot_count_indexed" "spot_count_ice" "indexing_rate" "indexing_lattice_count" "indexing_unit_cell_length" "indexing_unit_cell_angle" "profile_radius" "mosaicity" "b_factor" "error_pixels" "saturated_pixels" "image_collection_efficiency" "receiver_delay" "receiver_free_send_buf" "strong_pixels" "roi_sum" "roi_mean" "roi_max_count" "roi_pixels" "roi_weighted_x" "roi_weighted_y" "packets_received" "max_pixel_value" "resolution_estimate" "pixel_sum" "processing_time" "beam_center_x" "beam_center_y" "integrated_reflections" "image_scale_factor" "image_scale_cc" "image_scale_b" "compression_ratio" "ice_ring_score"

Type of requested plot

+
type
required
string
Enum: "bkg_estimate" "azint" "azint_1d" "spot_count" "spot_count_low_res" "spot_count_indexed" "spot_count_ice" "indexing_rate" "indexing_lattice_count" "indexing_unit_cell_length" "indexing_unit_cell_angle" "profile_radius" "mosaicity" "b_factor" "error_pixels" "saturated_pixels" "image_collection_efficiency" "receiver_delay" "receiver_free_send_buf" "strong_pixels" "roi_sum" "roi_mean" "roi_max_count" "roi_pixels" "roi_weighted_x" "roi_weighted_y" "packets_received" "max_pixel_value" "resolution_estimate" "pixel_sum" "processing_time" "beam_center_x" "beam_center_y" "integrated_reflections" "image_scale_factor" "image_scale_cc" "compression_ratio" "ice_ring_score"

Type of requested plot

fill
number <float>

Fill value for elements that were missed during data collection

experimental_coord
boolean
Default: false

If measurement has goniometer axis defined, plot X-axis will represent rotation angle If measurement has grid scan defined, plot X-axis and Y-axis will represent grid position, Z will be used as the final value @@ -901,7 +905,7 @@ For still measurement the number is ignored

http://localhost:5232/preview/plot

Response samples

Content type
application/json
{
  • "title": "string",
  • "unit_x": "image_number",
  • "size_x": 0.1,
  • "size_y": 0.1,
  • "plot": [
    ]
}

Generate 1D plot from Jungfraujoch and send in raw binary format. Data are provided as (32-bit) float binary array. This format doesn't transmit information about X-axis, only values, so it is of limited use for azimuthal integration. -

query Parameters
type
required
string
Enum: "bkg_estimate" "azint" "azint_1d" "spot_count" "spot_count_low_res" "spot_count_indexed" "spot_count_ice" "indexing_rate" "indexing_lattice_count" "indexing_unit_cell_length" "indexing_unit_cell_angle" "profile_radius" "mosaicity" "b_factor" "error_pixels" "saturated_pixels" "image_collection_efficiency" "receiver_delay" "receiver_free_send_buf" "strong_pixels" "roi_sum" "roi_mean" "roi_max_count" "roi_pixels" "roi_weighted_x" "roi_weighted_y" "packets_received" "max_pixel_value" "resolution_estimate" "pixel_sum" "processing_time" "beam_center_x" "beam_center_y" "integrated_reflections" "image_scale_factor" "image_scale_cc" "image_scale_b" "compression_ratio" "ice_ring_score"

Type of requested plot

+
query Parameters
type
required
string
Enum: "bkg_estimate" "azint" "azint_1d" "spot_count" "spot_count_low_res" "spot_count_indexed" "spot_count_ice" "indexing_rate" "indexing_lattice_count" "indexing_unit_cell_length" "indexing_unit_cell_angle" "profile_radius" "mosaicity" "b_factor" "error_pixels" "saturated_pixels" "image_collection_efficiency" "receiver_delay" "receiver_free_send_buf" "strong_pixels" "roi_sum" "roi_mean" "roi_max_count" "roi_pixels" "roi_weighted_x" "roi_weighted_y" "packets_received" "max_pixel_value" "resolution_estimate" "pixel_sum" "processing_time" "beam_center_x" "beam_center_y" "integrated_reflections" "image_scale_factor" "image_scale_cc" "compression_ratio" "ice_ring_score"

Type of requested plot

roi
string non-empty

Name of ROI for which plot is requested

Responses