// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 #include #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 &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(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(s.h), static_cast(s.k), static_cast(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 &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(std::hypot(s.x - beam_x, s.y - beam_y) * pixel_mm / BIN_MM); }; std::vector 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 ring(count.size(), 0); for (size_t i = 0; i < count.size(); i++) { std::vector w(count.begin() + static_cast(i > WINDOW_BINS ? i - WINDOW_BINS : 0), count.begin() + static_cast(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 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 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 next_stripe = 0; std::atomic 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(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(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> 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 strong; for (auto &f : frames) if (static_cast(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 placing = strong; if (static_cast(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(examined.load(), static_cast(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 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 again(strong.size()); std::atomic 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> 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(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 centres = config_.refine_modules && !modules_placed_ && given.size() >= 2 ? given.Centres() : std::vector{}; 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 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 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 &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::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::infinity(); return e.capped_rms_px; }; std::future 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 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::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::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 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 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 Rugnux::FindStillsSampleSpots(const std::vector &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 frames(ordinals.size()); std::atomic 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> 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::IndexStillsSample(const std::vector &hypotheses, IndexerThreadPool &pool) { auto settings = config_.spot_finding; settings.enable = true; settings.indexing = true; std::vector> indexers; std::vector> items; // (hypothesis, frame) for (size_t h = 0; h < hypotheses.size(); h++) { indexers.push_back(std::make_unique(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 total(hypotheses.size()); std::atomic next = 0; auto worker = [&]() { std::vector 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(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> 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> 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 sample = select_equally_spaced_image_ordinals( images_to_process, std::min(images_to_process, SAMPLE_FRAMES)); int scan_count = SCAN_FRAMES; std::vector 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(frames.size(), GRID_FRAMES)); scan_frames.assign(frames.begin(), frames.begin() + std::min(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 *>; std::map 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 &keys) { std::vector 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 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 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 &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> 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 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 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 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> 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> maxima; std::vector 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 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 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(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 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 sample = select_equally_spaced_image_ordinals( images_to_process, std::min(images_to_process, SATURATION_SAMPLE_FRAMES)); const std::vector &mask = pixel_mask_.GetMask(); std::mutex m; saturation_ceiling::Histogram total{}; std::atomic next = 0; auto worker = [&]() { saturation_ceiling::Histogram h{}; JFJochReaderRawImage img; std::vector buffer; std::vector 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(std::min( reinterpret_cast(ptr)[j], INT32_MAX)); break; case CompressedImageMode::Uint16: std::copy_n(reinterpret_cast(ptr), n, values.begin()); break; case CompressedImageMode::Int16: std::copy_n(reinterpret_cast(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> 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 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); }