Files
Jungfraujoch/rugnux/RugnuxStills.cpp
T
leonarski_f ea383f9e83 FFBIDXIndexerCPU: the fast-feedback indexer on the CPU, used where there is no GPU
A port of the fast-feedback indexer's CUDA kernels to plain C++/Eigen: the same half-sphere and
rotation samples, the same scores and the same 32 output cells for Refine(). The loops are
reorganised so the inner one runs over samples and vectorises (GCC/Clang, no intrinsics), and the
cell search separates the rotation about the candidate vector out of the per-spot terms. The
vector refinement reproduces the library's warp sums - partial sums its shuffle reduction leaves in
the other lanes carry into the next round - and its float arithmetic (FMA contraction, the
Fibonacci points in float), because the candidates searched depend on it: on the library's 27 test
spot lists and 4 synthetic stills the CPU and GPU best cells agree to ~1e-6 relative, the top-32
sets in 30 of 31 cases completely.

FFBIDX now resolves to the GPU library where a GPU is visible and to the CPU port otherwise (no
GPU, --no-gpu, or a build without CUDA); Auto takes FFBIDX for stills whenever a cell is known,
also without a GPU, instead of FFTW. With a GPU nothing changes (a stills set and a rotation set
reproduce the baseline exactly). Without a GPU the stills indexer pool gets one indexer per worker
instead of eight, since the CPU indexer holds no card memory.

Single core, synthetic still, loaded 5950X: ~8 ms per call at 30 spots, ~15 ms at 80, ~35 ms at
250; 16 threads ~1300/700/330 calls/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 9a8eb8669d1be6c3ec16db6786280affd6830ee5)
2026-10-23 17:56:50 +02:00

1192 lines
65 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cstdlib>
#include "Rugnux.h"
#include "../image_analysis/structure_refinement/ModelValidation.h"
#include "../image_analysis/indexing/SpindleCuspLoss.h"
#include "../image_analysis/bragg_integration/SpotWidth.h"
#include "../image_analysis/bragg_integration/SpotFootprint.h"
#include "../image_analysis/bragg_integration/BraggStencil.h" // BRAGG_FOOTPRINT_NSIGMA
#include "../image_analysis/hot_pixels/HotPixels.h"
#include "../image_analysis/SensorAbsorption.h"
#include "DiagnosticOutput.h"
#include "ModuleDiagnostics.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <deque>
#include <cstring>
#include <functional>
#include <future>
#include <limits>
#include <mutex>
#include <numeric>
#include <map>
#include <set>
#include <tuple>
#include <sstream>
#include <thread>
#include "../reader/JFJochHDF5Reader.h"
#include "../common/JFJochMath.h"
#include "../common/ParallelFor.h"
#include "../common/Logger.h"
#include "../common/AzimuthalIntegrationMapping.h"
#include "../common/AzimuthalIntegrationProfile.h"
#include "../common/CUDAWrapper.h"
#include "../common/JFJochException.h"
#include "../common/time_utc.h"
#include "../image_analysis/MXAnalysisWithoutFPGA.h"
#include "../image_analysis/beam_stop/ShadowFinder.h"
#include "../image_analysis/IndexAndRefine.h"
#include "../image_analysis/indexing/AnalyzeIndexing.h"
#include "../image_analysis/indexing/HarmonicContamination.h"
#include "../image_analysis/geom_refinement/BeamCenterFFT.h"
#include "../image_analysis/geom_refinement/BeamCenterFromBackground.h"
#include "../image_analysis/geom_refinement/GeometryRefiner.h"
#include "../image_analysis/indexing/IndexerThreadPool.h"
#include "../image_analysis/spot_finding/ImageSpotFinderCPU.h"
#include "../image_analysis/spot_finding/SpotUtils.h"
#include "gemmi/twin.hpp"
#include "../image_analysis/azint/AzIntEngineCPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorCPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorBuffer.h"
#include "../image_analysis/image_preprocessing/SaturationCeiling.h"
#ifdef JFJOCH_USE_CUDA
#include "../image_analysis/image_preprocessing/ImagePreprocessorGPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h"
#include "../image_analysis/spot_finding/AdaptiveSpotFinderGPU.h"
#endif
#include "../image_analysis/scale_merge/Merge.h"
#include "../image_analysis/scale_merge/RfreeFlags.h"
#include "../image_analysis/scale_merge/RotationScaleMerge.h"
#include "../image_analysis/scale_merge/ResolutionCutoff.h"
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
#include "../image_analysis/scale_merge/ScalingResult.h"
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
#include "../image_analysis/geom_refinement/PostRefine.h"
#include "../image_analysis/lattice_search/LatticeSearch.h"
#include "../image_analysis/lattice_search/LePageLattice.h"
#include "../image_analysis/scale_merge/AnisotropyAnalysis.h"
#include "../image_analysis/scale_merge/FrenchWilson.h"
#include "../image_analysis/scale_merge/TwinningAnalysis.h"
#include "../image_analysis/scale_merge/TranslationalNCS.h"
#include "../image_analysis/scale_merge/HKLKey.h"
#include "../image_analysis/scale_merge/ScaleOnTheFly.h"
#include "../image_analysis/scale_merge/StillsPartialityRefine.h"
#include "../image_analysis/WriteReflections.h"
#include "../image_analysis/bragg_integration/CalcISigma.h"
#include "../common/Definitions.h"
#include "../common/CorrelationCoefficient.h"
#include <array>
#include <map>
#include "RugnuxPipeline.h"
using namespace rugnux_internal;
namespace {
// The module of each spot: the panel the spot finder found it on, or - where the spot carries no panel id -
// the module where it lies on the detector plane (spots are on the plane already).
void AssignModules(std::vector<GeomRefineFrame> &frames, const DetectorModules &modules) {
for (auto &f: frames)
for (auto *list: {&f.spots, &f.unindexed})
for (auto &s: *list)
if (s.module < 0)
s.module = static_cast<int16_t>(modules.ModuleOfPlanePoint(s.x, s.y));
}
// A frame the geometry refinement can use: its spots on lattice 0 with their index, the others without.
GeomRefineFrame ToRefineFrame(const DataMessage &msg, int ordinal) {
GeomRefineFrame f;
f.lattice = *msg.indexing_lattice;
f.ordinal = ordinal;
for (const auto &s : msg.spots) {
if (s.indexed && s.lattice == 0)
f.spots.push_back(GeomRefineSpot{s.x, s.y,
static_cast<int32_t>(s.h), static_cast<int32_t>(s.k), static_cast<int32_t>(s.l), s.panel});
else
f.unindexed.push_back(GeomRefineSpot{s.x, s.y, 0, 0, 0, s.panel});
}
return f;
}
bool SameGeometry(const DiffractionGeometry &a, const DiffractionGeometry &b) {
return a.GetBeamX_pxl() == b.GetBeamX_pxl() && a.GetBeamY_pxl() == b.GetBeamY_pxl()
&& a.GetDetectorDistance_mm() == b.GetDetectorDistance_mm() && a.GetPixelSize_mm() == b.GetPixelSize_mm()
&& a.GetWavelength_A() == b.GetWavelength_A() && a.GetPoniRot1_rad() == b.GetPoniRot1_rad()
&& a.GetPoniRot2_rad() == b.GetPoniRot2_rad() && a.GetPoniRot3_rad() == b.GetPoniRot3_rad();
}
// Spots piled up at one radius - an ice or other powder ring - are not this crystal's lattice. They are
// found by their radius in millimetres about (beam_x, beam_y), not by a d-spacing: a d computed at a
// distance that may be wrong misses the rings, and then they are most of the spots a frame has.
// A 0.5 mm annulus is a ring where it holds at least 10 spots and over five times the median of the
// annuli within 10 mm of it; the annuli either side go with it. Returns how many spots were dropped.
size_t DropPowderRingSpots(std::vector<DataMessage> &frames, float beam_x, float beam_y, float pixel_mm) {
constexpr float BIN_MM = 0.5f;
constexpr int WINDOW_BINS = 20, MIN_SPOTS = 10, OVER_MEDIAN = 5;
const auto bin_of = [&](const SpotToSave &s) {
return static_cast<size_t>(std::hypot(s.x - beam_x, s.y - beam_y) * pixel_mm / BIN_MM);
};
std::vector<int> count;
for (const auto &f : frames)
for (const auto &s : f.spots) {
const size_t b = bin_of(s);
if (b >= count.size())
count.resize(b + 1, 0);
count[b]++;
}
std::vector<char> ring(count.size(), 0);
for (size_t i = 0; i < count.size(); i++) {
std::vector<int> w(count.begin() + static_cast<long>(i > WINDOW_BINS ? i - WINDOW_BINS : 0),
count.begin() + static_cast<long>(std::min(count.size(), i + WINDOW_BINS + 1)));
std::nth_element(w.begin(), w.begin() + w.size() / 2, w.end());
if (count[i] >= MIN_SPOTS && count[i] > OVER_MEDIAN * std::max(w[w.size() / 2], 1))
for (size_t j = (i > 0 ? i - 1 : 0); j <= std::min(count.size() - 1, i + 1); j++)
ring[j] = 1;
}
size_t dropped = 0;
for (auto &f : frames)
dropped += std::erase_if(f.spots, [&](const SpotToSave &s) { return ring[bin_of(s)] != 0; });
return dropped;
}
} // namespace
void Rugnux::RefineStillsGeometry(int start_image, int end_image, int images_to_process,
RugnuxObserver *observer) {
Logger logger("Rugnux");
// Rotation has its own two-pass; this is stills only.
if (experiment_.IsRotationIndexing()) {
logger.Warning("--refine-geometry is stills-only; rotation data uses two-pass indexing. Ignoring.");
return;
}
// The bundle anchors a known cell; without one there is nothing to refine against.
const auto cell = stills_user_cell_;
if (!cell.has_value()) {
logger.Warning("--refine-geometry needs a reference unit cell (-C / -S / reference MTZ). Skipping.");
return;
}
stills_tilt_bundle_.reset();
const int refine_frames = std::max(1, config_.refine_geometry.value());
if (observer)
observer->OnPhase("Geometry refinement (first pass)");
Step("Geometry refinement");
const auto dataset = reader_.GetDataset();
// Index a spread sample large enough to yield ~refine_frames strong frames even at a low hit rate.
const int sample_budget = std::min(images_to_process, std::max(refine_frames * 50, 8000));
const std::vector<int> sample = select_equally_spaced_image_ordinals(images_to_process, sample_budget);
// First-pass engines, built from the current (nominal) geometry: an index-only pass (no
// integration) to obtain each frame's spots + assigned HKL + orientation.
AzimuthalIntegrationMapping mapping(experiment_, pixel_mask_);
IndexerThreadPool pool(experiment_.GetIndexingSettings(), IndexerConstruction::OnFirstUse, gpu_work_beside_);
IndexAndRefine indexer(experiment_, &pool, /*retain_outcomes=*/false);
auto pass_settings = config_.spot_finding;
pass_settings.enable = true;
pass_settings.indexing = true;
pass_settings.quick_integration = false;
// A frame is worth bundling only if it indexed this many spots; the bundle then takes the
// refine_frames strongest of those.
constexpr int MIN_STRONG_SPOTS = 20;
// Stop sampling once there are comfortably more strong frames than the bundle will use, instead of
// draining the whole budget. The budget is sized for a low-hit-rate dataset (refine_frames * 50, at
// least 8000), so on data that indexes well it meant re-indexing the ENTIRE run to keep 200 frames -
// 99% of this pass was sampling. Four times the bundle leaves the "strongest N" selection a real
// pool to choose from.
// Placing the modules of a modular detector needs spots on the outer ones, which only the strong
// high-resolution frames reach, and the placement takes every strong frame sampled (see `placing`):
// there the sample goes five times further.
const bool placing_modules = config_.refine_modules && !modules_placed_ && experiment_.GetModules().size() >= 2;
const int strong_target = refine_frames * (placing_modules ? 20 : 4);
// The sample is cut into a FIXED number of interleaved stripes - stripe s is sample positions
// s, s+STRIPES, s+2*STRIPES, ... - and each stripe stops once it has contributed its share of the
// strong frames. Two things follow, both of which the previous shared-cursor-with-a-shared-stop
// arrangement got wrong. Every stripe is spread over the WHOLE run, so stopping early no longer
// means fitting the geometry to the beginning of it. And which frames get examined depends only
// on the data: it is not a race between the workers, and it does not change with -N, because a
// stripe is processed identically whichever worker happens to claim it.
constexpr int STRIPES = 32;
const int stripe_strong_target = std::max(1, (strong_target + STRIPES - 1) / STRIPES);
std::vector<GeomRefineFrame> frames;
std::mutex frames_mutex;
// Reported only: the signed excitation error of the indexed spots against d*^2. The spots' positions fix
// only distance x cell scale; the excitation error compares the cell with the Ewald sphere, so a slope
// against d*^2 says the cell (and with it the distance) is off in scale, and its sign which way.
double ex_n = 0, ex_x = 0, ex_y = 0, ex_xx = 0, ex_xy = 0, ex_yy = 0;
std::atomic<int> next_stripe = 0;
std::atomic<int> examined = 0;
auto worker = [&]() {
pin_gpu(); // round-robin per worker thread; must precede engine construction
// Before the analysis engine, which page-locks these bytes for its uploads and unregisters
// them when it is destroyed: it must not outlive the buffer it was given.
JFJochReaderRawImage img;
MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, indexer,
/*enable_fused_adaptive_gpu=*/true);
AzimuthalIntegrationProfile profile(mapping);
for (int stripe = next_stripe.fetch_add(1); stripe < STRIPES && !cancelled_;
stripe = next_stripe.fetch_add(1)) {
int stripe_strong = 0;
for (int idx = stripe; idx < static_cast<int>(sample.size()) && !cancelled_
&& stripe_strong < stripe_strong_target; idx += STRIPES) {
examined.fetch_add(1, std::memory_order_relaxed);
const int ordinal = sample[idx];
const int image_idx = start_image + ordinal * config_.stride;
bool read = false;
try {
read = reader_.ReadRawImage(image_idx, img);
} catch (const std::exception &e) {
if (IsFatalResourceError(e)) throw;
logger.Warning("Geometry refinement: failed to load image {}: {}", image_idx, e.what());
continue;
}
if (!read) continue;
DataMessage msg{};
msg.image = img.image;
msg.number = ordinal;
msg.original_number = image_idx;
if (dataset->efficiency.size() > image_idx)
msg.image_collection_efficiency = dataset->efficiency[image_idx];
try {
analysis.Analyze(msg, profile, pass_settings);
} catch (const std::exception &e) {
if (IsFatalResourceError(e)) throw;
continue;
}
indexer.RethrowIndexerError(); // a frame the indexer failed on is not one that did not index
if (!msg.indexing_result.value_or(false) || !msg.indexing_lattice.has_value())
continue;
GeomRefineFrame f = ToRefineFrame(msg, ordinal);
if (f.spots.size() >= 6) {
if (static_cast<int>(f.spots.size()) >= MIN_STRONG_SPOTS)
stripe_strong++;
const std::unique_lock ul(frames_mutex);
for (const auto &sp : msg.spots)
if (sp.indexed && sp.lattice == 0 && sp.d_A > 0) {
const double x = 1.0 / (sp.d_A * sp.d_A), y = sp.dist_ewald_sphere;
ex_n += 1; ex_x += x; ex_y += y; ex_xx += x * x; ex_xy += x * y; ex_yy += y * y;
}
frames.push_back(std::move(f));
}
}
}
};
// As many workers as the image pass: each drives the card the same way, and how many there are does not
// change which frames are examined (the stripes above).
TimingMark sampling_mark(logger, "geometry refinement sample");
std::vector<std::future<void>> futures;
for (int i = 0; i < ImageWorkers(); ++i)
futures.push_back(std::async(std::launch::async, worker));
for (auto &fut : futures)
fut.get();
sampling_mark.Stop();
if (cancelled_)
return;
// Keep the strongest frames (most indexed spots) for the bundle - the true cell indexes many
// spots per frame, and orientation diversity comes for free from independent serial stills.
std::vector<GeomRefineFrame> strong;
for (auto &f : frames)
if (static_cast<int>(f.spots.size()) >= MIN_STRONG_SPOTS)
strong.push_back(std::move(f));
// frames is in the order the workers happened to finish, so the spot count alone does not order
// it: without the ordinal tie-break, equally strong frames would swap places between runs and a
// different bundle would be refined.
std::sort(strong.begin(), strong.end(),
[](const GeomRefineFrame &a, const GeomRefineFrame &b) {
if (a.spots.size() != b.spots.size()) return a.spots.size() > b.spots.size();
return a.ordinal < b.ordinal;
});
// Placing modules needs spots on every module, and the outer ones see few: that fit takes every strong
// frame the sampling found, not only the bundle's share.
std::vector<GeomRefineFrame> placing = strong;
if (static_cast<int>(strong.size()) > refine_frames)
strong.resize(refine_frames);
logger.Info("Geometry refinement: indexed {} of {} sampled frames (budget {}), bundling {} strong "
"frames (>= {} spots)",
frames.size(), std::min<int>(examined.load(), static_cast<int>(sample.size())),
sample.size(), strong.size(), MIN_STRONG_SPOTS);
if (ex_n > 2) {
const double sxx = ex_xx - ex_x * ex_x / ex_n, sxy = ex_xy - ex_x * ex_y / ex_n, syy = ex_yy - ex_y * ex_y / ex_n;
const double slope = sxy / sxx;
const double se = std::sqrt(std::max(0.0, (syy - slope * sxy) / (ex_n - 2) / sxx));
logger.Info("Geometry refinement: excitation error of the indexed spots against d*^2 at {:.3f} mm: slope "
"{:+.2e} +- {:.1e} ({:.0f} spots)", experiment_.GetDetectorDistance_mm(), slope, se, ex_n);
}
GeometryRefinerSettings gr_settings;
gr_settings.crystal_system = experiment_.GetCrystalSystem();
gr_settings.num_threads = config_.nthreads;
// The distance and the cell are refined together, both free. Near the beam they are degenerate - a
// spot's radius fixes only their ratio - but not far from it: the radius goes as D tan(2 theta) while
// the cell scales sin(theta), so the strong frames' high-angle spots fix both. Holding either one
// puts its error on the other and from there on the high-angle predictions: a distance given 1.4%
// short, with the cell absorbing it, mispredicted the spots past 2.5 A by enough to make the merged
// intensities there negative. A deposited cell is often a percent off this crystal (another
// temperature, or the cell the depositors started from), and a stated distance can be off as much,
// so the cell (-C) and the file's distance (or DecideStillsGeometry's) are both only where the fit
// starts. A distance the user gave (--detector-distance) is held: the cell alone absorbs any error.
gr_settings.refine_distance = !config_.detector_distance_given;
gr_settings.cell_reg_coeff = 0.0;
const DiffractionGeometry nominal = experiment_.GetDiffractionGeometry();
GeometryRefinerResult r = RefineGlobalGeometry(nominal, *cell, strong, gr_settings);
if (!r.ok) {
logger.Warning("Geometry refinement did not run (too few strong frames / did not converge); "
"keeping the input geometry");
return;
}
// The bundle's spots were indexed at the geometry the fit started from, and the high-angle ones only
// where that geometry put them close enough to their reflections: a fit that has moved the distance far
// carries the start in which spots it rests on (two starts a percent apart ended 0.2 % apart). So where it moved the distance by more than REFIT_ABOVE, the bundle's frames are indexed
// again at the fitted geometry and cell and fitted again, until the distance stays put. Not where
// modules are being placed: the placement below is fitted on spots indexed at the start, as before.
constexpr double REFIT_ABOVE = 0.003, REFIT_CONVERGED = 0.0005;
constexpr int MAX_REFITS = 3;
double moved_by = std::abs(r.distance_mm / nominal.GetDetectorDistance_mm() - 1.0);
std::vector<GeomRefineFrame> refitted; // the bundle as last indexed again, if it was
for (int refit = 0; refit < MAX_REFITS && !placing_modules && moved_by > REFIT_CONVERGED
&& (refit > 0 || moved_by > REFIT_ABOVE) && !cancelled_; refit++) {
TimingMark refit_mark(logger, "geometry refit");
DiffractionExperiment e = experiment_;
e.BeamX_pxl(r.beam_x_px).BeamY_pxl(r.beam_y_px).DetectorDistance_mm(r.distance_mm);
e.SetUnitCell(r.cell);
AzimuthalIntegrationMapping refit_mapping(e, pixel_mask_);
IndexerThreadPool refit_pool(e.GetIndexingSettings(), IndexerConstruction::OnFirstUse, gpu_work_beside_);
IndexAndRefine refit_indexer(e, &refit_pool, /*retain_outcomes=*/false);
std::vector<GeomRefineFrame> again(strong.size());
std::atomic<size_t> next = 0;
auto refit_worker = [&]() {
pin_gpu();
JFJochReaderRawImage img;
MXAnalysisWithoutFPGA analysis(e, refit_mapping, pixel_mask_, refit_indexer,
/*enable_fused_adaptive_gpu=*/true);
AzimuthalIntegrationProfile profile(refit_mapping);
for (size_t i = next.fetch_add(1); i < strong.size() && !cancelled_; i = next.fetch_add(1)) {
const int image_idx = start_image + strong[i].ordinal * config_.stride;
if (!reader_.ReadRawImage(image_idx, img))
continue;
DataMessage msg{};
msg.image = img.image;
msg.number = strong[i].ordinal;
msg.original_number = image_idx;
if (dataset->efficiency.size() > image_idx)
msg.image_collection_efficiency = dataset->efficiency[image_idx];
analysis.Analyze(msg, profile, pass_settings);
if (msg.indexing_result.value_or(false) && msg.indexing_lattice.has_value())
again[i] = ToRefineFrame(msg, strong[i].ordinal);
}
};
std::vector<std::future<void>> refit_futures;
for (int i = 0; i < ImageWorkers(); ++i)
refit_futures.push_back(std::async(std::launch::async, refit_worker));
for (auto &fut : refit_futures)
fut.get();
refit_indexer.RethrowIndexerError();
std::erase_if(again, [&](const GeomRefineFrame &f) {
return static_cast<int>(f.spots.size()) < MIN_STRONG_SPOTS;
});
const GeometryRefinerResult r2 = RefineGlobalGeometry(e.GetDiffractionGeometry(), r.cell, again, gr_settings);
if (!r2.ok)
break;
moved_by = std::abs(r2.distance_mm / r.distance_mm - 1.0);
logger.Info("Geometry refinement: indexed again at the fitted geometry ({} frames), distance {:.4f} -> {:.4f} mm",
again.size(), r.distance_mm, r2.distance_mm);
r = r2;
refitted = std::move(again);
}
// The frames the tilt is decided on (DecideStillsTilt): the bundle as last fitted, or, where the modules are
// placed below, the frames they are placed on, with the modules where they are placed.
if (config_.calibration_refine_tilt && !config_.detector_tilt_given)
stills_tilt_bundle_ = StillsTiltBundle{refitted.empty() ? strong : refitted, {}};
// A detector of several modules: each placed as a rigid block, where that predicts the spots better on
// frames the placement was not fitted to. The strong frames are dealt alternately into two halves; each
// half is fitted with the modules held and with them moved, and each fit is scored on the other half
// with its geometry frozen. The modules move if the moved fits predict the held-out spots better.
// The modules are those the run was given (the file's, the reader's panels or --module-geometry), never
// guessed from the image; they are placed once, by the first RefineStillsGeometry.
const DetectorModules &given = experiment_.GetModules();
const std::vector<ModuleShift> centres = config_.refine_modules && !modules_placed_ && given.size() >= 2
? given.Centres() : std::vector<ModuleShift>{};
modules_placed_ = true;
if (!centres.empty()) {
AssignModules(placing, given);
AssignModules(strong, given);
// Where a module is off by more than the spots' spacing allows the refinement to pull, its spots
// went unindexed: each module is first searched for over +-SCAN_RANGE_PX on the bundle's frames,
// and the placement starts from the shift that puts the most of its spots on the lattices.
constexpr double SCAN_RANGE_PX = 25.0;
TimingMark scan_mark(logger, "module scan");
std::vector<ModuleShift> start = centres;
const auto scan = ScanModuleShifts(nominal, strong, centres, SCAN_RANGE_PX, config_.nthreads);
for (size_t m = 0; m < start.size(); m++)
if (scan[m].on_lattice_at_best > scan[m].on_lattice_at_zero) {
start[m].dx = scan[m].dx;
start[m].dy = scan[m].dy;
if (std::hypot(scan[m].dx, scan[m].dy) >= 1.0)
logger.Info("Module {} at ({:.0f}, {:.0f}): {} spots on the lattices as placed, {} shifted by "
"({:+.1f}, {:+.1f}) px", m, start[m].x0, start[m].y0, scan[m].on_lattice_at_zero,
scan[m].on_lattice_at_best, scan[m].dx, scan[m].dy);
}
scan_mark.Stop();
// Once the modules are placed, spots that went unindexed may sit on the lattice: the placement
// takes every spot and lets the fit sort them.
for (auto &f: placing) {
f.spots.insert(f.spots.end(), f.unindexed.begin(), f.unindexed.end());
f.unindexed.clear();
}
std::vector<GeomRefineFrame> half[2];
for (size_t i = 0; i < placing.size(); i++)
half[i % 2].push_back(placing[i]);
// The four held-out fits and the placement on every frame do not depend on each other, and one fit
// keeps only a few cores busy however many threads it is given (its Schur elimination contends on the
// shared blocks): all five run side by side. The placement, on twice the frames, is the one they wait
// for, so it gets half the threads and each held-out fit an eighth - measured on 32 threads (AGIPD,
// MPCCD, JF4M, JF16M) 10-15% faster than every fit on every thread, which mostly spins on the locks.
// The placement is computed whether or not the held-out residuals then call for it.
TimingMark placement_mark(logger, "module placement fits");
GeometryRefinerSettings st = gr_settings;
st.num_threads = std::max(1, config_.nthreads / 2);
st.modules = start;
st.refine_modules = true;
auto placement = std::async(std::launch::async, [&] {
return RefineGlobalGeometry(nominal, *cell, placing, st);
});
// Held-out residual of the geometry fitted on half `fit`, scored on every spot of the other half.
auto held_out = [&](const std::vector<ModuleShift> &modules, bool refine, int fit) {
GeometryRefinerSettings sf = gr_settings;
sf.num_threads = std::max(1, config_.nthreads / 8);
sf.modules = modules;
sf.refine_modules = refine;
const auto f = RefineGlobalGeometry(nominal, *cell, half[fit], sf);
if (!f.ok)
return std::numeric_limits<double>::infinity();
DiffractionGeometry g = nominal;
g.BeamX_pxl(f.beam_x_px).BeamY_pxl(f.beam_y_px).DetectorDistance_mm(f.distance_mm);
GeometryRefinerSettings ev = gr_settings;
ev.num_threads = sf.num_threads;
ev.refine_beam_distance = false;
ev.modules = f.modules;
const auto e = RefineGlobalGeometry(g, f.cell, half[1 - fit], ev);
if (!e.ok)
return std::numeric_limits<double>::infinity();
return e.capped_rms_px;
};
std::future<double> held_fits[2], moved_fits[2];
for (int fit = 0; fit < 2; fit++) {
held_fits[fit] = std::async(std::launch::async, held_out, std::cref(centres), false, fit);
moved_fits[fit] = std::async(std::launch::async, held_out, std::cref(start), true, fit);
}
const double held = (held_fits[0].get() + held_fits[1].get()) / 2.0;
const double moved = (moved_fits[0].get() + moved_fits[1].get()) / 2.0;
const GeometryRefinerResult rm = placement.get();
placement_mark.Stop();
logger.Info("Module placement: {} modules; held-out spot residual (capped rms) {:.3f} px as given, "
"{:.3f} px with each placed", centres.size(), held, moved);
if (moved < held) {
if (rm.ok) {
for (size_t m = 0; m < rm.modules.size(); m++) {
const auto &ms = rm.modules[m];
logger.Info("Module {} at ({:.0f}, {:.0f}): shift ({:+.2f}, {:+.2f}) px, turn {:+.3f} deg, {} spots",
m, ms.x0, ms.y0, ms.dx, ms.dy, ms.rot_rad * 180.0 / PI, ms.spots);
}
// The move is a step from where the modules were given; the experiment now has them there,
// so every spot found and every prediction integrated from here on uses it. The image is
// not touched.
if (config_.module_diagnostics) {
ModuleDiagnosticsInput diag{nominal, *cell, placing, centres, start, st, rm,
experiment_.GetDetectorSetup().GetSensorMaterial(),
experiment_.GetDetectorSetup().GetSensorThickness_um()};
RunModuleDiagnostics(diag);
}
module_shifts_ = rm.modules;
if (config_.apply_modules) {
if (stills_tilt_bundle_)
stills_tilt_bundle_ = StillsTiltBundle{placing, rm.modules};
DetectorModules placed = given;
placed.Apply(rm.modules);
experiment_.Modules(placed);
r = rm;
logger.Info("Module placement: modules placed (median residual {:.3f} px)", rm.median_residual_px);
} else
logger.Info("Module placement: measured and reported only - the run keeps the modules where "
"the file has them (median residual {:.3f} px with them placed)", rm.median_residual_px);
}
}
}
logger.Info("Geometry refinement: beam ({:.2f}, {:.2f}) -> ({:.2f}, {:.2f}) px, distance {:.4f} -> {:.4f} mm",
experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(), r.beam_x_px, r.beam_y_px,
experiment_.GetDetectorDistance_mm(), r.distance_mm);
logger.Info("Geometry refinement: cell a/b/c {:.3f}/{:.3f}/{:.3f} -> {:.3f}/{:.3f}/{:.3f} A "
"(median residual {:.3f} px, {} frames, {} spots)",
cell->a, cell->b, cell->c, r.cell.a, r.cell.b, r.cell.c,
r.median_residual_px, r.frames_used, r.spots_used);
experiment_.BeamX_pxl(r.beam_x_px).BeamY_pxl(r.beam_y_px).DetectorDistance_mm(r.distance_mm);
experiment_.SetUnitCell(r.cell);
}
// The detector tilt of a stills run, as a hypothesis against the tilt the run has (the file's, normally none):
// the strong frames of the geometry refinement are dealt alternately into two halves, each half is fitted with
// the tilt held and with it refined - the beam, the distance and the cell refined in both, the modules held
// where the run placed them - and each fit is scored on the other half with its geometry frozen. The tilt is
// kept if the refined fits predict the held-out spots better, and then refined the same way on every frame.
// The modules are not placed again: on half the frames a module they barely reach is not placed at all and goes
// back to where it was given, and refitted with the tilt on every frame they gave a worse merge than held. Where the tilt is real it bends the pattern in a way nothing
// else in the fit can (the keystone); where it is not, the gain on frames the fit has not seen is nothing and the
// tilt found is too small to matter either way, so no threshold has to say how large a tilt must be.
// The fits read only what they are given, so they can run beside the steps before the decision (StartStillsTiltAhead).
Rugnux::StillsTiltTrial Rugnux::TryStillsTilt(const StillsTiltBundle &bundle, const DiffractionGeometry &start,
const UnitCell &cell, GeometryRefinerSettings st) {
st.modules = bundle.modules;
std::vector<GeomRefineFrame> half[2];
for (size_t i = 0; i < bundle.frames.size(); i++)
half[i % 2].push_back(bundle.frames[i]);
// Held-out residual of the geometry fitted on half `fit`, with the tilt refined or held, scored on every
// spot of the other half.
auto held_out = [&](bool tilt, int fit) {
GeometryRefinerSettings sf = st;
sf.refine_tilt = tilt;
const auto f = RefineGlobalGeometry(start, cell, half[fit], sf);
if (!f.ok)
return std::numeric_limits<double>::infinity();
DiffractionGeometry g = start;
g.BeamX_pxl(f.beam_x_px).BeamY_pxl(f.beam_y_px).DetectorDistance_mm(f.distance_mm)
.PoniRot1_rad(f.rot1_rad).PoniRot2_rad(f.rot2_rad);
GeometryRefinerSettings ev = st;
ev.refine_beam_distance = false;
const auto e = RefineGlobalGeometry(g, f.cell, half[1 - fit], ev);
if (!e.ok)
return std::numeric_limits<double>::infinity();
return e.capped_rms_px;
};
// The four held-out fits and the tilted fit on every frame side by side, as for the module placement.
std::future<double> held_fits[2], tilt_fits[2];
for (int fit = 0; fit < 2; fit++) {
held_fits[fit] = std::async(std::launch::async, held_out, false, fit);
tilt_fits[fit] = std::async(std::launch::async, held_out, true, fit);
}
auto tilted = std::async(std::launch::async, [&] {
GeometryRefinerSettings sf = st;
sf.refine_tilt = true;
return RefineGlobalGeometry(start, cell, bundle.frames, sf);
});
StillsTiltTrial t{start, cell, st.crystal_system};
t.held = (held_fits[0].get() + held_fits[1].get()) / 2.0;
t.refined = (tilt_fits[0].get() + tilt_fits[1].get()) / 2.0;
t.tilted = tilted.get();
return t;
}
GeometryRefinerSettings Rugnux::StillsTiltSettings() const {
GeometryRefinerSettings st;
st.crystal_system = experiment_.GetCrystalSystem();
st.num_threads = config_.nthreads;
st.refine_distance = !config_.detector_distance_given;
st.cell_reg_coeff = 0.0;
return st;
}
void Rugnux::StartStillsTiltAhead() {
if (!stills_tilt_bundle_)
return;
stills_tilt_ahead_ = std::async(std::launch::async, TryStillsTilt, *stills_tilt_bundle_,
experiment_.GetDiffractionGeometry(), experiment_.GetUnitCell().value(),
StillsTiltSettings()).share();
}
void Rugnux::DecideStillsTilt(bool bundle_refitted) {
if (!stills_tilt_bundle_)
return;
Logger logger("Rugnux");
TimingMark mark(logger, "stills tilt decision");
std::optional<StillsTiltTrial> ahead;
if (stills_tilt_ahead_.valid()) {
ahead = stills_tilt_ahead_.get();
stills_tilt_ahead_ = {};
}
const StillsTiltBundle bundle = std::move(*stills_tilt_bundle_);
stills_tilt_bundle_.reset();
const DiffractionGeometry start = experiment_.GetDiffractionGeometry();
const UnitCell cell = experiment_.GetUnitCell().value();
const GeometryRefinerSettings st = StillsTiltSettings();
// The fits started ahead are these fits where they started from the same bundle, geometry and cell.
const bool use_ahead = ahead && !bundle_refitted && SameGeometry(ahead->start, start) && ahead->cell == cell
&& ahead->crystal_system == st.crystal_system;
logger.Debug("Detector tilt: the fits started beside the pre-scan are {}", use_ahead ? "used" : "not used");
const StillsTiltTrial t = use_ahead ? std::move(*ahead) : TryStillsTilt(bundle, start, cell, st);
const GeometryRefinerResult &r = t.tilted;
const bool keep = r.ok && t.refined < t.held;
logger.Info("Detector tilt: rot1 {:+.3f} deg, rot2 {:+.3f} deg refined ({:+.3f}, {:+.3f} held); held-out spot "
"residual (capped rms) {:.3f} px held, {:.3f} px refined, {} frames - the tilt is {}",
r.rot1_rad * 180.0 / PI, r.rot2_rad * 180.0 / PI, start.GetPoniRot1_rad() * 180.0 / PI,
start.GetPoniRot2_rad() * 180.0 / PI, t.held, t.refined, bundle.frames.size(),
keep ? "refined" : "held");
if (!keep)
return;
experiment_.BeamX_pxl(r.beam_x_px).BeamY_pxl(r.beam_y_px).DetectorDistance_mm(r.distance_mm)
.PoniRot1_rad(r.rot1_rad).PoniRot2_rad(r.rot2_rad);
experiment_.SetUnitCell(r.cell);
const auto [direct_x, direct_y] = experiment_.GetDiffractionGeometry().GetDirectBeam_pxl();
logger.Info("Detector tilt: direct beam ({:.2f}, {:.2f}) px, PONI ({:.2f}, {:.2f}) px, distance {:.4f} mm, "
"cell a/b/c {:.3f}/{:.3f}/{:.3f} A", direct_x, direct_y, r.beam_x_px, r.beam_y_px, r.distance_mm,
r.cell.a, r.cell.b, r.cell.c);
}
std::vector<DataMessage> Rugnux::FindStillsSampleSpots(const std::vector<int> &ordinals, int start_image) {
const auto dataset = reader_.GetDataset();
AzimuthalIntegrationMapping mapping(experiment_, pixel_mask_);
IndexAndRefine indexer(experiment_, nullptr, /*retain_outcomes=*/false);
auto settings = config_.spot_finding;
settings.enable = true;
settings.indexing = false;
settings.quick_integration = false;
// No resolution limits: they are d-spacings, evaluated at the distance the run has now, and this sample
// is what decides whether that distance is right. At a file's 839 mm for a detector at 106 mm the 50 A
// limit alone cut every spot within 20 mm of the beam.
settings.low_resolution_limit = std::nullopt;
settings.high_resolution_limit = std::nullopt;
std::vector<DataMessage> frames(ordinals.size());
std::atomic<size_t> next = 0;
auto worker = [&]() {
pin_gpu();
JFJochReaderRawImage img;
MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, indexer, /*enable_fused_adaptive_gpu=*/true);
AzimuthalIntegrationProfile profile(mapping);
for (size_t i = next.fetch_add(1); i < ordinals.size() && !cancelled_; i = next.fetch_add(1)) {
const int image_idx = start_image + ordinals[i] * config_.stride;
if (!reader_.ReadRawImage(image_idx, img))
continue;
DataMessage msg{};
msg.image = img.image;
msg.number = ordinals[i];
msg.original_number = image_idx;
if (dataset->efficiency.size() > image_idx)
msg.image_collection_efficiency = dataset->efficiency[image_idx];
analysis.Analyze(msg, profile, settings);
msg.image = {}; // the buffer is the worker's, and only the spots are kept
frames[i] = std::move(msg);
}
};
std::vector<std::future<void>> futures;
for (int i = 0; i < ImageWorkers(); ++i)
futures.push_back(std::async(std::launch::async, worker));
for (auto &f : futures)
f.get();
return frames;
}
std::vector<Rugnux::StillsTrial> Rugnux::IndexStillsSample(const std::vector<StillsHypothesis> &hypotheses,
IndexerThreadPool &pool) {
auto settings = config_.spot_finding;
settings.enable = true;
settings.indexing = true;
std::vector<std::unique_ptr<IndexAndRefine>> indexers;
std::vector<std::pair<size_t, size_t>> items; // (hypothesis, frame)
for (size_t h = 0; h < hypotheses.size(); h++) {
indexers.push_back(std::make_unique<IndexAndRefine>(hypotheses[h].x, &pool, /*retain_outcomes=*/false));
for (size_t i = 0; i < hypotheses[h].frames->size(); i++)
items.emplace_back(h, i);
}
std::mutex m;
std::vector<StillsTrial> total(hypotheses.size());
std::atomic<size_t> next = 0;
auto worker = [&]() {
std::vector<StillsTrial> t(hypotheses.size());
for (size_t n = next.fetch_add(1); n < items.size() && !cancelled_; n = next.fetch_add(1)) {
const auto [h, i] = items[n];
const DiffractionGeometry &geom = hypotheses[h].x.GetDiffractionGeometry();
// The spots where the frame put them, at this trial's geometry.
DataMessage msg = (*hypotheses[h].frames)[i];
for (auto &s : msg.spots) {
s.d_A = geom.PxlToRes(s.x, s.y);
s.indexed = false;
s.lattice = -1;
}
t[h].frames++;
if (!indexers[h]->IndexFrameOnly(msg, settings))
continue;
t[h].indexed++;
t[h].spots += static_cast<int64_t>(msg.spots.size());
t[h].on_lattice += msg.spot_count_indexed.value_or(0);
}
std::lock_guard lock(m);
for (size_t h = 0; h < t.size(); h++) {
total[h].frames += t[h].frames;
total[h].indexed += t[h].indexed;
total[h].spots += t[h].spots;
total[h].on_lattice += t[h].on_lattice;
}
};
std::vector<std::future<void>> futures;
for (int i = 0; i < config_.nthreads; ++i)
futures.push_back(std::async(std::launch::async, worker));
for (auto &f : futures)
f.get();
for (const auto &indexer : indexers)
indexer->RethrowIndexerError();
return total;
}
// The beam centre of a stills run - and, where a reference cell is given, the detector distance -
// decided on the frames rather than taken from the file. Both are written into a header by hand often
// enough to be wrong, and a still, unlike a rotation sweep, has no first pass that would show it: every
// frame is indexed on its own, so a wrong centre shows only as frames that index on a distorted lattice,
// or not at all, and a merge that does not agree with itself.
//
// So each is a hypothesis, indexed on the same sample of frames, and the run keeps the one that puts the
// most of the found spots on the lattice of their frame. The file's value is kept unless another puts
// materially more there, not merely more.
bool Rugnux::DecideStillsGeometry(int start_image, int images_to_process) {
Logger logger("Rugnux");
JoinBeamCenterCapture();
TimingMark mark(logger, "stills geometry trial");
constexpr int SAMPLE_FRAMES = 96;
// The distance is told apart far from the beam, so its searches run on the frames with the most spots:
// the scan below on SCAN_FRAMES of them (it only has to find the peak), the steps about each peak on
// GRID_FRAMES.
constexpr int SCAN_FRAMES = 8, GRID_FRAMES = 32;
// Below this fraction of the best hypothesis' count the file's value is outside the peak.
constexpr double FILE_KEPT_ABOVE = 0.9;
// The distance scan: STEP^k mm over [MIN_DISTANCE_MM, MAX_DISTANCE_MM]. The range is in millimetres,
// not in multiples of the file's value, so where the scan looks does not depend on the value it checks
// (a Cheetah file states 839 mm for a detector at 106 mm). The step is the peak's width: a distance
// 1.2 % off the peak still puts a third of its spots on the lattice, 3.6 % off almost none.
constexpr float STEP = 1.025f, MIN_DISTANCE_MM = 30.0f, MAX_DISTANCE_MM = 3000.0f;
// The indexing tolerance of the scan, in reciprocal-lattice units (the run's default is 0.1).
constexpr float SCAN_TOLERANCE = 0.2f;
// How many of the scan's local maxima are looked at more closely.
constexpr size_t SCAN_PEAKS = 3;
// A sample found at a distance this far from the one decided is found again there (see below).
constexpr float RESAMPLE_ABOVE = 0.05f;
// Where nothing indexes at any distance, the sample is this many times larger (see below).
constexpr int WIDER_SAMPLE = 10;
// Every centre below is a DIRECT BEAM, the point the undeflected beam lands on, and so is what the capture
// measures; the geometry's beam_x_pxl is the PONI, which parts from it by distance * tan(tilt) / pixel
// once the detector is tilted. A trial puts the PONI where its distance and the run's tilt land the direct
// beam on the centre tried (place). Without a tilt the two are the same point.
const auto place = [](DiffractionExperiment &e, float x, float y, float distance) {
e.DetectorDistance_mm(distance);
const auto [direct_x, direct_y] = e.GetDiffractionGeometry().GetDirectBeam_pxl();
e.BeamX_pxl(x + (e.GetBeamX_pxl() - direct_x)).BeamY_pxl(y + (e.GetBeamY_pxl() - direct_y));
};
const auto file_direct = experiment_.GetDiffractionGeometry().GetDirectBeam_pxl();
const float file_x = file_direct.first, file_y = file_direct.second;
const float file_distance = experiment_.GetDetectorDistance_mm();
// The background walk is fitted through the run's tilt, so it returns a PONI: its direct beam is as far
// from it as the file's direct beam is from the file's PONI.
std::optional<std::pair<float, float>> walk;
if (background_center_)
walk = std::pair{background_center_->beam_x_pxl + (file_x - experiment_.GetBeamX_pxl()),
background_center_->beam_y_pxl + (file_y - experiment_.GetBeamY_pxl())};
std::vector<int> sample = select_equally_spaced_image_ordinals(
images_to_process, std::min(images_to_process, SAMPLE_FRAMES));
int scan_count = SCAN_FRAMES;
std::vector<DataMessage> frames, strongest, scan_frames;
// The sample's spots, found at the run's present geometry; powder rings off about the background's
// centre where there is one (it does not depend on the distance), strongest frames first.
const auto find_sample = [&]() {
frames = FindStillsSampleSpots(sample, start_image);
const auto [bx, by] = walk ? *walk : experiment_.GetDiffractionGeometry().GetDirectBeam_pxl();
const size_t dropped = DropPowderRingSpots(frames, bx, by, experiment_.GetPixelSize_mm());
if (dropped > 0)
logger.Info("Stills geometry trial: {} sampled spots on powder rings left out", dropped);
std::stable_sort(frames.begin(), frames.end(), [](const DataMessage &a, const DataMessage &b) {
return a.spots.size() > b.spots.size();
});
strongest.assign(frames.begin(), frames.begin() + std::min<size_t>(frames.size(), GRID_FRAMES));
scan_frames.assign(frames.begin(), frames.begin() + std::min<size_t>(frames.size(), scan_count));
};
find_sample();
// As many indexer threads as the image pass has (RunPipeline): the hypotheses are indexed side by side, and
// with the default four the workers waited on the pool.
IndexingSettings pool_settings = experiment_.GetIndexingSettings();
pool_settings.IndexingThreads(StillsIndexerThreads());
IndexerThreadPool pool(pool_settings, IndexerConstruction::OnFirstUse, gpu_work_beside_);
// A hypothesis is indexed once: the searches below meet the same one more than once - the decided centre
// is the file's whenever the file's is kept - and it gives the same count every time. The hypotheses a
// search asks are indexed together (try_all) - a hypothesis' count does not depend on what is indexed
// beside it - and then read one by one, in the order the search compares them.
using TrialKey = std::tuple<float, float, float, const std::vector<DataMessage> *>;
std::map<TrialKey, int64_t> tried;
// Hypotheses indexed in one go: enough to keep every worker and the indexer pool busy on 8 frames each,
// few enough that their experiments and indexers stay small.
constexpr size_t HYPOTHESES_AT_ONCE = 64;
const auto try_all = [&](const std::vector<TrialKey> &keys) {
std::vector<TrialKey> todo;
for (const auto &key : keys)
if (!tried.contains(key) && std::find(todo.begin(), todo.end(), key) == todo.end())
todo.push_back(key);
for (size_t c0 = 0; c0 < todo.size(); c0 += HYPOTHESES_AT_ONCE) {
const size_t c1 = std::min(todo.size(), c0 + HYPOTHESES_AT_ONCE);
std::vector<StillsHypothesis> hypotheses;
for (size_t k = c0; k < c1; k++) {
const auto &[x, y, distance, on] = todo[k];
DiffractionExperiment e = experiment_;
place(e, x, y, distance);
if (stills_user_cell_)
e.SetUnitCell(*stills_user_cell_); // the grid asks the user's cell, not the one refined at the file's distance
// The distance scan's frames are indexed with a wider acceptance: the scan only has to find the peak, and
// it looks at centres a few pixels off and at distances up to half a step off, either of which leaves the
// strict tolerance with nothing on a large cell at a long distance (a 92 A cell at 199 mm indexed on no
// centre 3 px off). Everything after the scan is counted at the run's own tolerance.
if (on == &scan_frames) {
IndexingSettings wide = e.GetIndexingSettings();
wide.Tolerance(SCAN_TOLERANCE);
e.ImportIndexingSettings(wide);
}
hypotheses.push_back(StillsHypothesis{std::move(e), on});
}
const std::vector<StillsTrial> results = IndexStillsSample(hypotheses, pool);
for (size_t k = c0; k < c1; k++) {
const auto &[x, y, distance, on] = todo[k];
const StillsTrial &t = results[k - c0];
logger.Debug("Stills geometry trial: centre ({:.2f},{:.2f}) distance {:.2f} mm - {}/{} frames indexed, "
"{} of their {} spots on the lattice", x, y, distance, t.indexed, t.frames,
t.on_lattice, t.spots);
tried[todo[k]] = t.on_lattice;
}
}
};
const auto trial = [&](float x, float y, float distance, const std::vector<DataMessage> &on) {
const TrialKey key{x, y, distance, &on};
try_all({key});
return tried.at(key);
};
// The centres: the file's, the measured one and the point-symmetry capture's - two estimators of the
// same background, which do not fail alike (a shadow across the background biases the isotropy fit,
// a flat background leaves the capture without a peak).
std::vector<std::pair<float, float>> candidates;
if (walk)
candidates.push_back(*walk);
if (point_symmetry_center_)
candidates.push_back(*point_symmetry_center_);
float x = file_x, y = file_y;
int64_t score_here = 0;
const auto decide_centre = [&](float distance) {
std::vector<TrialKey> keys{{file_x, file_y, distance, &frames}};
for (const auto &[cx, cy] : candidates)
keys.emplace_back(cx, cy, distance, &frames);
try_all(keys);
const int64_t file_score = trial(file_x, file_y, distance, frames);
int64_t best = file_score;
float best_x = file_x, best_y = file_y;
for (const auto &[cx, cy] : candidates) {
const int64_t score = trial(cx, cy, distance, frames);
logger.Info("Stills beam centre at {:.2f} mm: ({:.2f},{:.2f}) puts {} sampled spots on the lattice, "
"the file's ({:.2f},{:.2f}) {}", distance, cx, cy, score, file_x, file_y, file_score);
if (score > best) {
best = score;
best_x = cx;
best_y = cy;
}
}
const bool moved = file_score < FILE_KEPT_ABOVE * best;
x = moved ? best_x : file_x;
y = moved ? best_y : file_y;
score_here = moved ? best : file_score;
};
decide_centre(file_distance);
// A file that states no distance (an XTC) has a placeholder there, not a hypothesis.
if (config_.detector_distance_unknown)
score_here = 0;
// The distance, where a reference cell is given. With the cell fixed each frame is matched rather than
// searched for, so a trial is cheap - and only then is the distance a hypothesis of its own: without a
// cell a wrong distance indexes as a cell of the wrong size, which only the curvature far from the beam
// tells apart. The stills geometry refinement takes it from there (RunPipeline).
//
// Like the beam centre's capture, the search is global first and local after: a scan over the whole
// range at every centre hypothesis, then the strongest of the scans' peaks a step either side on more
// frames, then a finer step about the best on the whole sample, which also decides against the file's
// value. A distance the user gave (--detector-distance) is not searched: it is the user's decision, e.g. a
// calibrated distance where the reference cell is the less certain of the two.
float distance = file_distance;
if (experiment_.GetUnitCell().has_value() && !config_.detector_distance_given) {
std::vector<float> grid;
for (float d = MIN_DISTANCE_MM; d <= MAX_DISTANCE_MM; d *= STEP)
grid.push_back(d);
float best_distance = file_distance, best_x = x, best_y = y;
int64_t best = score_here;
const auto fine = [&](float about, float cx, float cy) {
std::vector<TrialKey> keys;
for (const float f : {0.98f, 0.99f, 1.0f, 1.01f, 1.02f})
keys.emplace_back(cx, cy, about * f, &frames);
try_all(keys);
for (const float f : {0.98f, 0.99f, 1.0f, 1.01f, 1.02f}) {
const int64_t score = trial(cx, cy, about * f, frames);
if (score > best) {
best = score;
best_distance = about * f;
best_x = cx;
best_y = cy;
}
}
};
const auto search = [&]() {
best_distance = file_distance;
best_x = x;
best_y = y;
best = score_here;
// Every centre hypothesis is scanned: on a sparse lattice the distance and the centre are found only
// together - a set whose file states 180 mm for a detector at 135 mm indexed at no distance from a
// background centre 7 px off, and only at the file's centre, 14 px off the other way.
std::vector<std::pair<float, float>> centres{{x, y}};
for (const auto &c : candidates)
if (std::find(centres.begin(), centres.end(), c) == centres.end())
centres.push_back(c);
if (std::find(centres.begin(), centres.end(), std::pair{file_x, file_y}) == centres.end())
centres.emplace_back(file_x, file_y);
// The local maxima of every centre's scan, strongest first; a distance is looked at once.
std::vector<std::pair<int64_t, size_t>> maxima;
std::vector<TrialKey> keys;
for (const auto &[cx, cy] : centres)
for (const float d : grid)
keys.emplace_back(cx, cy, d, &scan_frames);
try_all(keys);
for (const auto &[cx, cy] : centres) {
std::vector<int64_t> scan(grid.size());
for (size_t i = 0; i < grid.size(); i++)
scan[i] = trial(cx, cy, grid[i], scan_frames);
for (size_t i = 0; i < grid.size(); i++)
if (scan[i] > 0 && (i == 0 || scan[i] >= scan[i - 1]) && (i + 1 == grid.size() || scan[i] >= scan[i + 1]))
maxima.emplace_back(scan[i], i);
}
std::stable_sort(maxima.begin(), maxima.end(), [](const auto &a, const auto &b) { return a.first > b.first; });
std::vector<size_t> peaks;
for (const auto &[score, i] : maxima)
if (peaks.size() < SCAN_PEAKS && std::find(peaks.begin(), peaks.end(), i) == peaks.end())
peaks.push_back(i);
float grid_best = file_distance, grid_x = x, grid_y = y;
int64_t grid_score = -1;
keys.clear();
for (const size_t p : peaks)
for (size_t i = (p > 0 ? p - 1 : 0); i <= std::min(grid.size() - 1, p + 1); i++)
for (const auto &[cx, cy] : centres)
keys.emplace_back(cx, cy, grid[i], &strongest);
try_all(keys);
for (const size_t p : peaks)
for (size_t i = (p > 0 ? p - 1 : 0); i <= std::min(grid.size() - 1, p + 1); i++)
for (const auto &[cx, cy] : centres) {
const int64_t score = trial(cx, cy, grid[i], strongest);
if (score > grid_score) {
grid_score = score;
grid_best = grid[i];
grid_x = cx;
grid_y = cy;
}
}
if (!peaks.empty())
fine(grid_best, grid_x, grid_y);
};
search();
// Nothing indexed at any distance: on a run with few crystal hits the strongest frames of the sample
// can all be empty ones (a set with a hit rate of a few percent had none among its 8 strongest of 96).
// The sample is then made larger and the search run again on it, the scan on twice as many frames.
if (best <= 0 && static_cast<int>(sample.size()) < images_to_process) {
sample = select_equally_spaced_image_ordinals(
images_to_process, std::min(images_to_process, WIDER_SAMPLE * SAMPLE_FRAMES));
scan_count = 2 * SCAN_FRAMES;
find_sample();
tried.clear();
decide_centre(file_distance);
if (config_.detector_distance_unknown)
score_here = 0;
logger.Info("Stills detector distance: nothing indexed at any distance, searched again on {} frames",
frames.size());
search();
}
if (score_here < FILE_KEPT_ABOVE * best) {
distance = best_distance;
x = best_x;
y = best_y;
if (stills_user_cell_)
experiment_.SetUnitCell(*stills_user_cell_);
}
logger.Info("Stills detector distance: the file's {:.2f} mm puts {} sampled spots on the lattice, the best "
"between {:.0f} and {:.0f} mm {} at {:.2f} mm - the run uses {:.2f} mm",
file_distance, score_here, MIN_DISTANCE_MM, MAX_DISTANCE_MM, best, best_distance, distance);
// The sample's spots were found at the file's distance, with the spot finder's d-dependent thresholds
// there; from a distance this far off they are found again where the run now is, and the fine step
// asked again on them (a sample found eight times too far out put a seventh as many spots on the
// lattice at the right distance as one found nearer).
if (std::abs(distance / file_distance - 1.0f) > RESAMPLE_ABOVE) {
place(experiment_, x, y, distance);
find_sample();
tried.clear();
best = -1;
fine(distance, x, y);
logger.Info("Stills detector distance: sampled again at {:.2f} mm, {} spots on the lattice at {:.2f} mm",
distance, best, best_distance);
distance = best_distance;
}
// A centre decided at a distance this far off had little indexing to go on; decided again here.
if (distance != file_distance)
decide_centre(distance);
}
// With a cell, also a local grid of centres about the one decided, at the distance decided: the two
// background estimators can both be several pixels off (a shadowed or uneven background), and the
// geometry refinement only moves the centre a few pixels. Coarse, then fine about the coarse best.
if (experiment_.GetUnitCell().has_value()) {
float gx = x, gy = y;
int64_t g_best = trial(x, y, distance, strongest);
for (const auto &[step, reach] : {std::pair{3.0f, 4}, std::pair{1.0f, 2}}) {
const float cx0 = gx, cy0 = gy;
std::vector<TrialKey> keys;
for (int i = -reach; i <= reach; i++)
for (int j = -reach; j <= reach; j++)
if (i != 0 || j != 0)
keys.emplace_back(cx0 + step * i, cy0 + step * j, distance, &strongest);
try_all(keys);
for (int i = -reach; i <= reach; i++)
for (int j = -reach; j <= reach; j++) {
if (i == 0 && j == 0)
continue;
const int64_t score = trial(cx0 + step * i, cy0 + step * j, distance, strongest);
if (score > g_best) {
g_best = score;
gx = cx0 + step * i;
gy = cy0 + step * j;
}
}
}
try_all({{x, y, distance, &frames}, {gx, gy, distance, &frames}});
const int64_t here = trial(x, y, distance, frames), there = trial(gx, gy, distance, frames);
logger.Info("Stills beam centre grid at {:.2f} mm: ({:.2f},{:.2f}) puts {} sampled spots on the lattice, "
"({:.2f},{:.2f}) {}", distance, gx, gy, there, x, y, here);
if (here < FILE_KEPT_ABOVE * there) {
x = gx;
y = gy;
}
}
logger.Info("Stills beam centre: the run uses the direct beam at ({:.2f},{:.2f}){}", x, y,
x == file_x && y == file_y ? ", the file's" : "");
place(experiment_, x, y, distance);
return x != file_x || y != file_y || distance != file_distance;
}
// Where this run's pixel values stop, read off the frames (saturation_ceiling): a sample spread over
// the run, every unmasked pixel of it. Measured on every stills run and reported beside what the file
// states. It replaces the file's limit only where the frames pile up below it and the file is not one
// Jungfraujoch wrote (config_.detect_saturation) - a file that states no limit, or the ADC's full
// range where each pixel's dark has already been taken off, otherwise integrates clipped peaks as if
// they were measured, and the strongest reflections at low resolution are the ones it spoils.
// The histogram reads only the frames and the mask, so it is taken beside the geometry refinement; the
// limit is decided once both are done (DetectSaturation), where the refinement leaves the experiment.
saturation_ceiling::Histogram Rugnux::SampleSaturation(int start_image, int images_to_process) {
Logger logger("Rugnux");
TimingMark mark(logger, "saturation ceiling");
const std::vector<int> sample = select_equally_spaced_image_ordinals(
images_to_process, std::min(images_to_process, SATURATION_SAMPLE_FRAMES));
const std::vector<uint32_t> &mask = pixel_mask_.GetMask();
std::mutex m;
saturation_ceiling::Histogram total{};
std::atomic<size_t> next = 0;
auto worker = [&]() {
saturation_ceiling::Histogram h{};
JFJochReaderRawImage img;
std::vector<uint8_t> buffer;
std::vector<int32_t> values;
for (size_t i = next.fetch_add(1); i < sample.size() && !cancelled_; i = next.fetch_add(1)) {
if (!reader_.ReadRawImage(start_image + sample[i] * config_.stride, img))
continue;
const size_t n = img.image.GetWidth() * img.image.GetHeight();
const uint8_t *ptr = img.image.GetUncompressedPtr(buffer);
values.resize(n);
switch (img.image.GetMode()) {
case CompressedImageMode::Int32:
std::memcpy(values.data(), ptr, n * sizeof(int32_t));
break;
case CompressedImageMode::Uint32:
for (size_t j = 0; j < n; j++)
values[j] = static_cast<int32_t>(std::min<uint32_t>(
reinterpret_cast<const uint32_t *>(ptr)[j], INT32_MAX));
break;
case CompressedImageMode::Uint16:
std::copy_n(reinterpret_cast<const uint16_t *>(ptr), n, values.begin());
break;
case CompressedImageMode::Int16:
std::copy_n(reinterpret_cast<const int16_t *>(ptr), n, values.begin());
break;
default:
continue; // a float or colour image has no integer ceiling to find
}
saturation_ceiling::Add(h, values, mask);
}
std::lock_guard lock(m);
for (size_t b = 0; b < h.size(); b++)
total[b] += h[b];
};
std::vector<std::future<void>> futures;
for (int i = 0; i < config_.nthreads; ++i)
futures.push_back(std::async(std::launch::async, worker));
for (auto &f : futures)
f.get();
return total;
}
void Rugnux::DetectSaturation(const saturation_ceiling::Histogram &total, int images_to_process) {
Logger logger("Rugnux");
const int frames = std::min(images_to_process, SATURATION_SAMPLE_FRAMES);
const int64_t stated = experiment_.GetSaturationLimit();
const std::optional<int64_t> ceiling = saturation_ceiling::Ceiling(total);
if (!ceiling) {
logger.Info("Saturation: the values of {} sampled frames fall off without piling up below any level - "
"nothing clips them under the file's limit of {}", frames, stated);
return;
}
const bool adopt = config_.detect_saturation && *ceiling < stated;
logger.Info("Saturation: the values of {} sampled frames pile up from {} - the file's limit is {}{}",
frames, *ceiling, stated,
adopt ? ", so pixels from there on are treated as overloaded"
: *ceiling < stated ? "; the stated limit is kept (written by Jungfraujoch, or given with --saturation)" : "");
if (adopt)
experiment_.Detector().SaturationLimit(*ceiling);
}