// 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 "../writer/WriteModel.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 #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 "../writer/FileWriter.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" #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 { // A detector-plane displacement resolved ACROSS the spindle - the only component the tolerance // above is about. Along the spindle the error translates the derotated cloud rigidly and the FFT // amplitude cannot see it at all; across it the peaks smear and the amplitude falls. (The lattice // fit that follows the FFT is not translation-invariant, so "along the spindle" is free for the // transform and not for the run - which is why this is reported and not used to decide anything.) // The detector direction wanted is the one whose lab image is perpendicular to the spindle n: // (ux e1 + uy e2).n = 0, so u is proportional to (-(e2.n), e1.n). std::optional AcrossSpindle_pxl(const DiffractionExperiment &experiment, float dx, float dy) { const auto goniometer = experiment.GetGoniometer(); if (!goniometer || !goniometer->IsScanning()) return {}; const auto geom = experiment.GetDiffractionGeometry(); const float beam_x = geom.GetBeamX_pxl(), beam_y = geom.GetBeamY_pxl(); const Coord n = goniometer->GetAxis().Normalize(); const Coord origin = geom.LabCoord(beam_x, beam_y); const Coord e1 = geom.LabCoord(beam_x + 1.0f, beam_y) - origin; const Coord e2 = geom.LabCoord(beam_x, beam_y + 1.0f) - origin; const float ux = -(e2 * n), uy = e1 * n; const float length = std::hypot(ux, uy); if (length < 1e-3f) return {}; return std::abs(dx * ux + dy * uy) / length; } // Images left out at each end of the sweep. Shutter synchronisation disturbs the first and last // frame or two of a run, and a partly exposed frame is dark over the whole detector - which is // exactly the kind of error a projection cannot average away and a background comparison reads // as signal. The margin is capped at a tenth of the sweep so a short run still has a sample. constexpr int PRESCAN_END_MARGIN_IMAGES = 5; // Workers reading the pre-scan sample. Each holds detector-sized buffers of its own, and pages of // fresh memory are slow to fault in when many threads do it at once, so this is capped well below // the worker count of the run proper. The beam-stop projection is memory-bound rather than // compute-bound, so a handful of workers already saturates it. constexpr size_t PRESCAN_MAX_WORKERS = 8; // The spot width is measured on a GROWING share of the pre-scan sample: every eighth frame of // it, then every fourth, then every second, then all of it - an eighth, a quarter, a half and // the whole. Each stride is a multiple of the next, so a tier's sample really does contain the // one before it, and the measurement stops at the first tier whose answer has settled // (spot_width::WidthSettled, in the loop below). A crystal whose frames carry plenty of isolated // strong spots is characterised from a quarter of the sample; a sparse one still gets all of it. // // The tiers add PRESCAN_MAX_WORKERS-sized steps as well: on a 60 frame sample they are 8, 15, 30 // and 60 frames, i.e. 8 + 7 + 15 + 30 read in the four passes, so no pass leaves most of the // workers idle waiting for the others to finish theirs. A ladder that starts smaller measures // fewer frames but costs more wall clock, because a pass of five frames occupies eight workers // for as long as a pass of eight does. // // Only the width work is tiered. Every frame of the sample is still READ once - the frames are // simply visited in tier order - and the beam-stop projection still sees all of them, so the // mask and the beam centre are what they were. What the tiers cut is the per-frame work the // width needs on top of the read: the host decompression, the preprocessing and the spot // finding, which together are two thirds of the pre-scan's cycles once the projection itself is // decoding on the GPU. The width measurement proper is under 1 % of them. // // Measured over the rotation battery: the radius chosen is the whole-sample answer on 38 of 38 // crystals, from an average of 32 frames of the 60 instead of all of them. constexpr std::array WIDTH_TIER_STRIDE = {8, 4, 2, 1}; } void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, RugnuxObserver *observer) { Logger logger("Rugnux"); // Two-pass rotation runs this twice. The shadow does not move, and re-detecting would find // nothing (its pixels are masked by now) and so clear the mask the first pass established. const auto ¤t = pixel_mask_.GetMask(); const bool have_shadow = std::any_of(current.begin(), current.end(), [](uint32_t v) { return (v & (1u << PixelMask::BeamStopPixelBit)) != 0; }); const bool want_shadow = config_.detect_beam_stop.has_value() && !have_shadow; // The spot width is a property of the crystal, not of the geometry, so the second pass reuses the // radius the first one measured rather than re-reading the frames. const bool want_width = config_.adaptive_integration_radius && !spot_width_measured_; // Defective pixels, for the same reason on the same pass: each pixel is compared against its // resolution ring. Only under rotation, where a reflection moves off a pixel and a defect does not. const auto goniometer = experiment_.GetGoniometer(); const bool want_hot = !hot_pixels_measured_ && experiment_.IsRotationIndexing() && goniometer && goniometer->IsScanning(); if (!want_shadow && !want_width && !want_hot) return; // The sample is taken from the frames given - on a rotation sweep its first part (PrepassEnd) - // both ends left out: an even spread over [0, images_to_process) always picks the very first and // the very last image, and those are the ones a shutter out of step spoils - the last carries // under 90 % of the sample's median counts on 12 of 38 regression sweeps and is essentially // unexposed on 5. const int margin = std::min(PRESCAN_END_MARGIN_IMAGES, images_to_process / 10); auto shadow_sample = select_equally_spaced_image_ordinals(images_to_process - 2 * margin, frame_count); for (int &ordinal : shadow_sample) ordinal += margin; const std::set shadow_set(shadow_sample.begin(), shadow_sample.end()); if (shadow_set.empty()) return; // Say what this is reading for. It runs before the first image of the run proper is processed, // so without this the job sits at its initial status for the whole pre-scan. if (observer) observer->OnPhase(want_shadow ? "Beam stop" : "Spot width"); Step("Pre-scan"); ShadowFinder finder(experiment_, pixel_mask_); // The spot width is read in the same pass as the projection rather than in a pre-pass of its own. // The image is preprocessed a second time for it: the shadow is accumulated from raw counts and // the spot finder works on the converted image. const bool want_spots = want_width; // The spots are measured in the background while the shadow, the defective pixels and the beam // centre are found (see below), and those write the mask. So the spot measurement reads its own // copies of the experiment and of the mask as they are now - which is what it always read, since // it used to finish before any of them started. const DiffractionExperiment prescan_x = experiment_; const PixelMask prescan_mask = pixel_mask_; // Built by the spot measurement, the only thing that reads it, so that it is built beside the // shadow rather than ahead of it. std::unique_ptr prescan_mapping; // Everything reading one frame needs that cannot be shared between threads: its own scratch for // each of the two decompressions, its own preprocessed image and its own spot finder. struct PreScanWorker { std::vector decompression_buffer; JFJochReaderRawImage raw_image; std::unique_ptr preprocessor; std::unique_ptr preprocessed; std::unique_ptr spot_finder; }; const auto make_worker = [&] { PreScanWorker w; #ifdef JFJOCH_USE_CUDA // On the card where there is one, as in the image loops: the frame is decoded and preprocessed // there and the spots are found and extracted there. The device finders give the host's spot // list to the bit - integer ring sums, the same connected components in the same order // (AdaptiveSpotFinderGPU, SpotExtractorGPU) - so this is a choice of where, not of what. The // preprocessed image comes back only for the spot width, which reads pixels around the spots. if (want_spots && get_gpu_count() > 0) { auto stream = std::make_shared(); w.preprocessor = std::make_unique(prescan_x, prescan_mask, stream, /*copy_image_to_host=*/want_width); w.preprocessed = std::make_unique(prescan_x.GetPixelsNum(), /*host_mirror=*/want_width); if (config_.spot_finding.adaptive_threshold) w.spot_finder = std::make_unique(*prescan_mapping, stream); else w.spot_finder = std::make_unique(prescan_x.GetXPixelsNumConv(), prescan_x.GetYPixelsNumConv(), stream); return w; } #endif if (want_spots) { w.preprocessor = std::make_unique(prescan_x, prescan_mask); w.preprocessed = std::make_unique(prescan_x.GetPixelsNum()); if (config_.spot_finding.adaptive_threshold) w.spot_finder = std::make_unique(*prescan_mapping); else w.spot_finder = std::make_unique(prescan_x.GetXPixelsNumConv(), prescan_x.GetYPixelsNumConv()); } return w; }; // The spots of one image, and what is taken off them. const auto find_spots = [&](PreScanWorker &w, CompressedImage &image, int image_idx, bool for_width, std::vector &curves, std::vector &spot_q, std::vector &footprint) { try { // As in the image loops: a frame the device cannot decode goes to the host decoder. ImageStatistics stats; bool decoded_on_device = false; try { decoded_on_device = w.preprocessor->AnalyzeCompressed(*w.preprocessed, image, stats); } catch (const JFJochException &e) { cuda_throw_if_context_lost(); logger.Warning("Pre-scan: device decoding of image {} failed ({}), decompressing it on " "the host", image_idx, e.what()); cuda_clear_error(); } if (!decoded_on_device) w.preprocessor->Analyze(*w.preprocessed, image.GetUncompressedPtr(w.decompression_buffer), image.GetMode()); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Pre-scan: failed to preprocess image {}: {}", image_idx, e.what()); return; } auto spots = w.spot_finder->Run(*w.preprocessed, config_.spot_finding); // Every frame's WHOLE spot list feeds the powder measurement below - the rings are a property // of the sample, and the frames read here are already spread over the sweep, which is what // sharpens them against the crystal's own rows. { const auto &g = prescan_x.GetDiffractionGeometry(); for (const auto &spot : spots) { const Coord c = spot.RawCoord(); const float d = g.PxlToRes(c.x, c.y); if (d > 0.0f && std::isfinite(d)) spot_q.push_back(2 * PI / d); } } // The width goes first: it wants the whole spot list, to know which spots are isolated. if (for_width) { MeasureSpotFluxCurves(*w.preprocessed, static_cast(prescan_x.GetXPixelsNumConv()), static_cast(prescan_x.GetYPixelsNumConv()), prescan_x.GetDiffractionGeometry(), spots, curves); // The footprint is measured on every spot, not only the isolated ones the width takes: // it is the far, wide spots it is for (SpotFootprint.h). std::vector fx, fy; for (const auto &spot : spots) { const Coord c = spot.RawCoord(); fx.push_back(c.x); fy.push_back(c.y); } const auto &g = prescan_x.GetDiffractionGeometry(); MeasureFootprintSpots(w.preprocessed->data(), static_cast(prescan_x.GetXPixelsNumConv()), static_cast(prescan_x.GetYPixelsNumConv()), g.GetBeamX_pxl(), g.GetBeamY_pxl(), fx, fy, footprint); } }; std::vector prescan_spot_q; std::vector width_curves; // The spots the bandwidth is measured on: the width's, and more where those are too few // (spot_width::BANDWIDTH_POOL_SPOTS). std::vector shape_curves; // Every spot's measured widths, for the spot footprint (SpotFootprint.h). std::vector footprint_spots; // Images the width ended up being measured on, for the log: the tiers below stop as soon as the // answer has settled, so this is a property of the crystal and worth reporting. size_t width_images = 0; // Frames that could actually be read. Counted here rather than asked of the shadow finder, which // is only fed when the shadow is wanted and would report zero for a pass that read every frame // for the beam centre alone. std::atomic frames_read{0}; // Read the sample on several workers. The reader serialises on the HDF5 lock, but the // decompression, the projection and the spot finding - which is all of the cost on a large // detector - run in parallel. The projection is integer sums, so the order the workers add their // frames in does not reach it, and the per-frame results are stitched together in sample order // below so the answer does not depend on how the workers interleaved. // // Two passes over the sample. The first builds the projection, which is all the shadow, the // defective pixels and the beam-centre capture below read; the second finds the spots, for the // spot width, the bandwidth and the powder rings, and runs in the // background beside them - it depends on none of them and none of them on it. What it measures // is applied once both are done (finish_spot_measurement). const std::vector ordinals(shadow_set.begin(), shadow_set.end()); const size_t nworkers = std::min(std::max(config_.nthreads, 1), std::min(PRESCAN_MAX_WORKERS, ordinals.size())); { std::atomic next{0}; std::vector> futures; futures.reserve(nworkers); for (size_t t = 0; t < nworkers; t++) futures.emplace_back(std::async(std::launch::async, [&] { std::vector shadow_buffer; JFJochReaderRawImage raw_image; for (size_t i = next.fetch_add(1); i < ordinals.size(); i = next.fetch_add(1)) { const int ordinal = ordinals[i]; const int image_idx = start_image + ordinal * config_.stride; bool read = false; try { read = reader_.ReadRawImage(image_idx, raw_image); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Pre-scan: failed to load image {}: {}", image_idx, e.what()); continue; } if (!read) continue; frames_read.fetch_add(1, std::memory_order_relaxed); // Gated on want_shadow, not on the frame set alone: AddImage projects the whole // image, and with --detect-beam-stop=off (or a shadow already in the mask) that is // the pass's largest per-frame cost paid for a result nobody reads. The set still // decides WHICH frames feed it, so the mask is built from the frames it always was. if (want_shadow && shadow_set.contains(ordinal)) { DataMessage msg{}; msg.image = raw_image.image; msg.number = ordinal; msg.original_number = image_idx; finder.AddImage(msg, shadow_buffer); } } })); for (auto &f : futures) f.get(); } const auto spot_measurement = [&] { prescan_mapping = std::make_unique(prescan_x, prescan_mask, *azint_geometry_); std::vector> curves_of(ordinals.size()); std::vector> spot_q_of(ordinals.size()); std::vector> footprint_of(ordinals.size()); // Frames read for the bandwidth alone, past the tier the width settled on. Their spots stay // out of the powder measurement, which is taken on the frames it always was. std::vector shape_only(ordinals.size(), 0); // The order the frames are visited in, and where each width tier ends in it. Without a width // to measure there is one tier and the sample is read in its own order. std::vector visit; std::vector tier_end; visit.reserve(ordinals.size()); { std::vector taken(ordinals.size(), 0); const size_t n_tier = want_width ? WIDTH_TIER_STRIDE.size() : 1; for (size_t tier = 0; tier < n_tier; tier++) { const size_t stride = want_width ? WIDTH_TIER_STRIDE[tier] : 1; for (size_t j = 0; j < ordinals.size(); j += stride) if (!taken[j]) { taken[j] = 1; visit.push_back(j); } tier_end.push_back(visit.size()); } } // Kept across the tiers rather than rebuilt for each. A worker's preprocessed image is a // buffer the size of the detector, so each is still built on its own worker the first time // that worker runs and its pages are touched by the thread that reads them. std::vector workers(nworkers); std::vector worker_built(nworkers, 0); bool width_settled = !want_width; // The bandwidth reads the same spots, but its slope needs more of them than the width does: // the tiers carry on past the width's for it alone, until the pool is large enough. bool shapes_settled = !want_width; std::optional previous_r80; size_t phase_begin = 0; for (const size_t phase_end : tier_end) { std::atomic next{phase_begin}; std::vector> futures; futures.reserve(nworkers); for (size_t t = 0; t < nworkers; t++) futures.emplace_back(std::async(std::launch::async, [&, t] { if (!worker_built[t]) { workers[t] = make_worker(); worker_built[t] = 1; } PreScanWorker &w = workers[t]; for (size_t v = next.fetch_add(1); v < phase_end; v = next.fetch_add(1)) { const size_t i = visit[v]; const int ordinal = ordinals[i]; const int image_idx = start_image + ordinal * config_.stride; const bool for_width = !width_settled; const bool for_shape = width_settled && !shapes_settled; if (!(for_width || for_shape)) continue; // nothing to measure on this frame bool read = false; try { read = reader_.ReadRawImage(image_idx, w.raw_image); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Pre-scan: failed to load image {}: {}", image_idx, e.what()); continue; } if (!read) continue; if (for_shape) shape_only[i] = 1; find_spots(w, w.raw_image.image, image_idx, for_width || for_shape, curves_of[i], spot_q_of[i], footprint_of[i]); } })); for (auto &f : futures) f.get(); phase_begin = phase_end; // What this tier makes of the width, pooled in sample order so the answer does not // depend on how the workers interleaved. A tier too sparse to measure settles nothing: // the test is against what the smaller sample actually said, not against the radius it // fell back to. if (shapes_settled) continue; if (!width_settled) { width_images = phase_end; width_curves.clear(); for (const auto &c : curves_of) width_curves.insert(width_curves.end(), c.begin(), c.end()); const auto r80 = spot_width::R80AtReference(width_curves); width_settled = r80 && previous_r80 && spot_width::WidthSettled(*r80, *previous_r80); previous_r80 = r80; } shape_curves.clear(); for (const auto &c : curves_of) shape_curves.insert(shape_curves.end(), c.begin(), c.end()); shapes_settled = width_settled && shape_curves.size() >= spot_width::BANDWIDTH_POOL_SPOTS; } for (size_t i = 0; i < ordinals.size(); i++) if (!shape_only[i]) prescan_spot_q.insert(prescan_spot_q.end(), spot_q_of[i].begin(), spot_q_of[i].end()); // In sample order, so the table does not depend on how the workers interleaved. for (const auto &f : footprint_of) footprint_spots.insert(footprint_spots.end(), f.begin(), f.end()); }; std::future spots_measured; if (want_spots) spots_measured = std::async(std::launch::async, spot_measurement); // What the spot measurement found, applied to the run once it has finished: called on every way // out of the pre-scan below. const auto finish_spot_measurement = [&] { if (spots_measured.valid()) spots_measured.get(); // The integration radius from the recorded spot width. r1 is the aperture the integrator learns // the profile width over and a second moment over a disk of radius a saturates at a^2/4, so at the // shipped r1 = 4 the learned sigma cannot exceed 2 px and a wider spot is fitted with a profile // the model cannot represent. r2 follows r1, and r3 is taken so that the r2..r3 background ring // keeps the area it has at the shipped 4/6/13 - a ring that shrinks with the disk is what makes a // bare --integration-radius worse than the default it replaces. if (want_width) { spot_width_measured_ = true; const auto r80 = spot_width::R80AtReference(width_curves); if (!r80) { logger.Info("Spot width: not measurable on {} spots from {} images, keeping the " "integration radius at r1={:.1f}", width_curves.size(), width_images, experiment_.GetBraggIntegrationSettings().GetR1()); } else { const float r1 = spot_width::R1ForWidth(*r80); const float r2 = r1 + 2.0f; const float r3 = std::sqrt(r2 * r2 + 133.0f); BraggIntegrationSettings bis = experiment_.GetBraggIntegrationSettings(); // Remembered where the APERTURE moved, so the starvation guard in RunAllPasses has // something to give back and cannot announce a fallback that changes nothing. // // The test is on all three radii and not on r1 alone. It is the same test today - r2 and // r3 are taken from r1 just above, and the adaptive rule does not run at all when the // radii were given explicitly - so this changes no run. It is written this way because // what the guard is about is the aperture, and r1 is only a proxy for it. A prototype // that widened r2 and r3 at high angle while leaving r1 at the shipped 4 took the // neighbour-starved fraction of a dense low-symmetry crystal from 2.4% to 9.0%, eight // times the bound this guard exists to enforce, and the guard could not fire: nothing // had set bragg_before_adaptive_, because r1 had not moved. if (r1 != bis.GetR1() || r2 != bis.GetR2() || r3 != bis.GetR3()) bragg_before_adaptive_ = bis; bis.R1(r1).R2(r2).R3(r3); experiment_.ImportBraggIntegrationSettings(bis); logger.Info("Spot width: r80 = {:.2f} px at {:.0f} A ({} spots from {} images) => " "integration radii r1={:.1f} r2={:.1f} r3={:.2f}", *r80, spot_width::D_REF_A, width_curves.size(), width_images, r1, r2, r3); Note(fmt::format("spot width {:.1f} px: integration radii {:.1f} / {:.1f} / {:.1f} px", *r80, r1, r2, r3)); } // How far the spots reach away from the beam, where the radius chosen above at 5 A no // longer holds them (SpotFootprint.h). It changes the integration only where a spot // outgrows the r1 disk. const auto &g = experiment_.GetDiffractionGeometry(); const float bx = g.GetBeamX_pxl(), by = g.GetBeamY_pxl(); const float W = static_cast(experiment_.GetXPixelsNum()); const float H = static_cast(experiment_.GetYPixelsNum()); const SpotFootprint fp = FootprintFromSpots(footprint_spots, std::hypot(std::max(bx, W - bx), std::max(by, H - by))); if (!fp.empty()) prescan_footprint_ = fp; // It acts only where a spot outgrows the r1 disk (BraggStencil.h), so a pattern of compact // spots is left on exactly the settings - and the passes - it had without it. const float r1_now = experiment_.GetBraggIntegrationSettings().GetR1(); bool outgrows = false; for (size_t b = 0; b < fp.sigma_rad.size(); ++b) outgrows |= BRAGG_FOOTPRINT_NSIGMA * std::max(fp.sigma_rad[b], fp.sigma_tan[b]) > r1_now; if (fp.empty()) { logger.Info("Spot footprint: too few spots to tabulate ({})", footprint_spots.size()); } else if (!outgrows) { logger.Info("Spot footprint from {} spots: every spot fits the r1={:.1f} disk", footprint_spots.size(), r1_now); } else { std::string table; for (size_t b = 0; b < fp.sigma_rad.size(); ++b) table += fmt::format(" {:.0f}:{:.1f}/{:.1f}", (b + 0.5f) * fp.bin_px, fp.sigma_rad[b], fp.sigma_tan[b]); logger.Info("Spot footprint from {} spots, sigma radial/tangential [px] by distance from " "the beam [px]:{}", footprint_spots.size(), table); // On the adaptive side, like the radius: the geometry pre-pass integrates without it, // and a canonical pass whose wider rings the neighbours starve falls back to the // settings before it (bragg_before_adaptive_, see RunAllPasses). BraggIntegrationSettings bis = experiment_.GetBraggIntegrationSettings(); if (!bragg_before_adaptive_) bragg_before_adaptive_ = bis; bis.Footprint(fp); experiment_.ImportBraggIntegrationSettings(bis); } } // The X-ray bandwidth, read off the shapes of the same spots (spot_width::EstimateBandwidth). A // value the file states, or --bandwidth, wins; otherwise a significant estimate is the bandwidth // of the run - prediction, partiality, the profile's radial width and the background stencil all // take it - and anything short of significant leaves the beam monochromatic. if (want_width) { const auto &det = experiment_.GetDetectorSetup(); const double lambda = experiment_.GetWavelength_A(); const auto estimate = spot_width::EstimateBandwidth( shape_curves, sensor_absorption::AttenuationLength_um(det.GetSensorMaterial(), lambda), det.GetSensorThickness_um(), experiment_.GetPixelSize_mm() * 1000.0); const bool stated = experiment_.GetBandwidthFWHM().has_value(); const bool significant = estimate && estimate->z > spot_width::BANDWIDTH_Z; if (!stated && significant) experiment_.BandwidthFWHM(static_cast(estimate->fwhm)); const float used = experiment_.GetBandwidthFWHM().value_or(0.0f); const char *why = stated ? "as stated" : significant ? "as measured" : "monochromatic"; if (!estimate) logger.Info("Bandwidth: not measurable on {} spots; the run uses {:.4f} ({})", shape_curves.size(), used, why); else logger.Info("Bandwidth: spot shapes give FWHM {:.4f} from {} spots (one standard error is " "FWHM {:.4f}; z = {:.1f}, chi2 {:.1f}) - {}; the run uses {:.4f} ({})", estimate->fwhm, estimate->spots, estimate->fwhm_floor, estimate->z, estimate->chi2, significant ? "significant" : "not significant", used, why); } // Powder contamination, measured on every run that finds spots here. A crystalline phase other // than the crystal - hexagonal ice, a shower of microcrystals, salt out of the cryoprotectant - // diffracts as rings, and its spots are handed to the indexer and to scaling as if they were this // crystal's. Only ice can be named in advance; the rest has to be measured, and measuring it costs // nothing here, the spots having been found for the width and the centre already. // // Reported on every run whatever it says. What is DONE about it is decided later and on evidence: // a first pass that fails indexes again with the rings set aside and keeps whichever answer indexes // more frames (see RunPipeline). This line is the diagnostic, and it is the one that tells a user // their crystal was in a powder. { const float w = config_.spot_finding.ice_ring_width_Q_recipA; powder_ = MeasurePowderRings(prescan_spot_q, w); prescan_spot_quantile_d_A_.clear(); for (const float frac : {0.25f, 0.5f}) if (const auto d = SpotResolutionQuantile(prescan_spot_q, frac)) prescan_spot_quantile_d_A_.push_back(*d); if (!powder_.rings_q_recipA.empty()) { logger.Info("Powder rings: {} rings over {} pooled spots hold {:.0f}% of them above the " "smooth spot density - the coarsest at {:.2f} A, the finest at {:.2f} A{}", powder_.rings_q_recipA.size(), prescan_spot_q.size(), 100.0f * powder_.spot_fraction, 2 * PI / powder_.rings_q_recipA.front(), 2 * PI / powder_.rings_q_recipA.back(), powder_.resolved_to_d_A ? fmt::format("; past {:.2f} A the rings have merged and cannot be told " "from the crystal", *powder_.resolved_to_d_A) : std::string()); Note(fmt::format("{} powder ring{} ({} ice), {:.0f}% of the spots", powder_.rings_q_recipA.size(), powder_.rings_q_recipA.size() == 1 ? "" : "s", powder_.ice_ring_count, 100.0f * powder_.spot_fraction)); } } }; if (frames_read.load() == 0) { logger.Warning("Pre-scan: no image could be read. Skipping."); finish_spot_measurement(); return; } // Where the scattered background puts the beam, when the projection exists to say so. std::optional> ring_centre; std::vector opaque_shadow; // The mean projection, taken once and shared read-only by everything below that reads it - the // ring-centre fit here, and the diagnostic picture and the beam-centre capture on threads of // their own. It is 4 bytes a pixel, and a copy for each of them was a copy on this thread. std::shared_ptr> projection; if (want_shadow) { // Every pixel is compared against the ring it sits on, so the rings have to be drawn about // the beam. A centre far enough out draws them across the background's own radial fall-off // instead of along it, and what comes back describes the fall-off rather than the hardware: // displacing a centre by 50 px on a clean sweep leaves the mask where it was, by 150 px // masks a tenth of the detector. A centre in a file can be that wrong - one sweep here // states one 351 px from where the data put it - so the rings are drawn about the centre // this same projection measures rather than the one the file claims. That fit costs no // frame of its own, and an unmasked shadow biases it by a few pixels at most, which is // three orders below what this comparison would notice. The centre the run reports and // consumes is the one fitted below instead, with the shadow out of the way. projection = std::make_shared>(finder.GetMeanProjection()); if (const auto ring_center = FindBeamCenterFromBackground(experiment_, pixel_mask_, *projection)) { finder.BeamCenter(ring_center->beam_x_pxl, ring_center->beam_y_pxl); ring_centre = std::make_pair(ring_center->beam_x_pxl, ring_center->beam_y_pxl); logger.Info("Beam stop: comparing each pixel against its ring about the measured " "centre ({:.2f},{:.2f}) +- {:.2f} px, {:.2f} px from the file's", ring_center->beam_x_pxl, ring_center->beam_y_pxl, ring_center->sigma_pxl, std::hypot(ring_center->beam_x_pxl - experiment_.GetBeamX_pxl(), ring_center->beam_y_pxl - experiment_.GetBeamY_pxl())); } else { logger.Info("Beam stop: the background does not place a centre on this run, so the " "rings are drawn about the file's ({:.2f},{:.2f})", experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl()); } const auto shadow = finder.GetMask(config_.nthreads); finder.ReleaseProjection(); const auto shadowed = std::count_if(shadow.begin(), shadow.end(), [](uint32_t v) { return v != 0; }); pixel_mask_.LoadBeamStopMask(experiment_, shadow); opaque_shadow = shadow; for (auto &v : opaque_shadow) if (v == ShadowFinder::TRANSMITTING) v = 0; // Not "this beamline has no beam stop": it is also what a comparison drawn about the wrong // point returns, so an unexpected answer here is worth reading against the centre named // above first. if (shadowed == 0) logger.Warning("Beam stop: no shadow found in {} images. The comparison is made about " "the centre named above, so this may point at that centre rather than " "at the experiment", finder.GetFrameCount()); else { logger.Info("Beam stop shadow: {} pixels ({:.2f}% of the detector) found in {} images", shadowed, 100.0 * static_cast(shadowed) / static_cast(shadow.size()), finder.GetFrameCount()); Note(fmt::format("beam stop masked: {:.2f}% of the detector", 100.0 * static_cast(shadowed) / static_cast(shadow.size()))); } // The diagnostic picture, rendered here because this is where the projection and the mask // the detection has just written into it are both in hand; the file is written later, under // the canonical prefix, since on a two-pass run this is the pre-pass. if (config_.mode == ProcessMode::FullAnalysis && experiment_.IsRotationIndexing()) detector_diagnostic_jpeg_ = std::async( std::launch::async, [x = experiment_, mask = pixel_mask_, projection] { return RenderDetectorDiagnostic(x, mask, *projection); }).share(); } // The beam centre is fitted again here, so that the shadow is in the mask by the time it is // placed and the holder arm - the largest azimuthal asymmetry on the detector - is out of the // way of the background the centre is read from. This is the answer the run reports and // consumes; the fit above only chose where to draw the rings, and is thrown away. // What the scattered background makes the centre, measured on EVERY run and committed on none. // The fit reads the projection the beam-stop pre-pass has just built and no frame of its own, so // it costs a fraction of a second on top of a pass that has already run - which is what makes it // affordable to do always, and doing it always is the point. A header beam centre is the metadata // field the field jokes about, and nothing in any package says how wrong a given one is: on a run // that indexes and merges perfectly well this line still reports that the file claims a centre the // data put 73 px away. The run is not touched by it. What consumes the number is the second first // pass in RunPipeline. // // Only where the projection exists: with --detect-beam-stop=off nothing accumulates it, and // projecting every frame for this alone is the largest per-frame cost of the pre-scan. On the // second pass of a two-pass run the shadow is already in the mask, so this does not run again - // by then the centre has been post-refined and the file's value is no longer what is in it. if (want_hot) MaskDefectivePixels(start_image, shadow_sample, ring_centre); // The centre is fitted with the shadow masked but NOT the transmitting pieces the shadow finder // adds after it. Their dimming is multiplicative in azimuth, which the fit's per-sector amplitude // absorbs; masked, a thin arm running into the beam takes cells out of the rings it crosses, and // which rings stay covered then changes as the trial centre moves - a walk seeded beside such an // arm stalled on that edge 44 px from the centre it reaches with the arm left in. PixelMask centre_mask = pixel_mask_; if (!opaque_shadow.empty()) centre_mask.LoadBeamStopMask(experiment_, opaque_shadow); // It reads nothing the steps after it change, and nothing reads its answer before the first pass's // beam-centre check (RunPipeline), so it runs beside them - on copies of what it measures - and is // taken there (JoinBeamCenterCapture). It logs from its own thread. if (want_shadow && config_.beam_center_check) background_center_capture_ = std::async(std::launch::async, [x = experiment_, mask = centre_mask, projection, nthreads = config_.nthreads] { Logger logger("Rugnux"); const auto t0 = std::chrono::steady_clock::now(); BeamCenterFFTResult capture; const std::optional background_center = FindBeamCenter(x, mask, *projection, nthreads, &capture); const auto capture_ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); if (!capture.point.empty()) logger.Info("Beam centre capture: the strongest point-symmetry centre on the WHOLE " "detector is ({:.1f},{:.1f}), {:.1f} px from the file's; line mirrors put x " "at {:.1f} and y at {:.1f}; margins {:.1f}%/{:.1f}%/{:.1f}% (point/x/y, " "under ~3% the surface is flat). One transform set scores every candidate " "centre, so this costs the same however wrong the file is - {} ms including " "the walk that refines it", capture.point[0].beam_x_pxl, capture.point[0].beam_y_pxl, std::hypot(capture.point[0].beam_x_pxl - x.GetBeamX_pxl(), capture.point[0].beam_y_pxl - x.GetBeamY_pxl()), capture.line_x.empty() ? std::numeric_limits::quiet_NaN() : capture.line_x[0].beam_x_pxl, capture.line_y.empty() ? std::numeric_limits::quiet_NaN() : capture.line_y[0].beam_y_pxl, 100.0f * capture.margin_point, 100.0f * capture.margin_line_x, 100.0f * capture.margin_line_y, capture_ms); const float need = BeamCenterNeed_pxl(x); if (!background_center) { logger.Info("Beam centre check: neither the capture nor the walk places the centre on " "this run - the file's ({:.2f},{:.2f}) is neither confirmed nor contradicted", x.GetBeamX_pxl(), x.GetBeamY_pxl()); } else { // Against the DIRECT BEAM, not the PONI. Both the FFT capture and the walk read the // centrosymmetry of the scattered background about the point the beam lands on; the // geometry's beam_x_pxl is the PONI, which parts from it by distance*tan(rot)/pixel as // soon as the detector is tilted. Measured on a calibrant at 110/150/200/300 mm the // capture sits 0.5-1.5 px from the fitted direct beam and 5.0/6.8/8.4/17.9 px from the // fitted PONI, so comparing it with the PONI reports a disagreement the data do not // have - on the in-house files, whose header carries a 0.234 deg tilt, 3.3 px of it at // 120 mm against a stated need of 0.7 px. const auto [direct_x, direct_y] = x.GetDiffractionGeometry().GetDirectBeam_pxl(); const float dx = background_center->beam_x_pxl - direct_x; const float dy = background_center->beam_y_pxl - direct_y; const float moved = std::hypot(dx, dy); const auto across = AcrossSpindle_pxl(x, dx, dy); // Everything a reader needs to judge the header, on one line: what the file says, what the // background says, how far apart they are, how well the background knows its own answer, // and what this particular geometry asks of the centre. The last is what turns a bare pixel // count into a verdict - 3 px is nothing at 100 mm and a lost lattice at 500 mm. logger.Info("Beam centre check: the file puts the direct beam at ({:.2f},{:.2f}), the " "scattered background " "puts it at ({:.2f},{:.2f}) +- {:.2f} px - a difference of {:.2f} px, against the " "{:.2f} px this geometry asks the centre to be right to. Measured only; the " "run keeps the centre in the file", direct_x, direct_y, background_center->beam_x_pxl, background_center->beam_y_pxl, background_center->sigma_pxl, moved, need); // Three sigma, because the fit is a PRECISION and not an accuracy: on a background with no // curvature there is nothing to separate shift from amplitude and it follows the noise in // g', confidently. A move it cannot resolve is not evidence about the header either way. if (moved <= 3.0f * background_center->sigma_pxl) logger.Info("Beam centre check: that is under three times the fit's own sigma, so the " "file's centre is as good as this measurement can tell - the second first " "pass below indexes it anyway, because a fraction of a pixel is enough to " "decide a cell and no sigma small enough to gate on would notice"); else if (across && *across > need) // Info and not a warning: `need` is where the FFT amplitude at a 200 A axis has fallen // to 0.70, which is a floor under what matters and not a threshold for failure - it is // exceeded on two of three ordinary in-house sweeps whose lattice comes out right. What // is worth a warning is what the second pass below then finds, not this. logger.Info("Beam centre check: {:.2f} px of the difference is ACROSS the spindle, more " "than the {:.2f} px this geometry absorbs, and a centre error is fixed in " "the lab frame - it smears the accumulated reciprocal-space cloud, so the " "first pass can lose peaks or take an axis harmonic. The second first pass " "below indexes both centres and reports whether it did", *across, need); else logger.Info("Beam centre check: that is a real difference, and {} - the second first " "pass below indexes both centres and compares what they give", across ? "it is mostly along the spindle, where nothing announces itself" : "this is not a rotation sweep, so there is no spindle to resolve it on"); } return background_center; }).share(); finish_spot_measurement(); } void Rugnux::MaskDefectivePixels(int start_image, const std::vector &sample, const std::optional> &ring_centre) { Logger logger("Rugnux"); hot_pixels_measured_ = true; // The frames are read a second time, because the rings each pixel is compared against have to // be drawn about the beam, and the centre the file states can be far enough out that a ring // crosses the background's own radial fall-off - the pixels on its bright side then read high on // every frame. The centre the beam-stop projection has just measured does not exist until every // frame of the first read is in. DiffractionExperiment about = experiment_; if (ring_centre) about.BeamX_pxl(ring_centre->first).BeamY_pxl(ring_centre->second); const size_t nthreads = static_cast(std::max(config_.nthreads, 1)); HotPixelFinder finder(about, pixel_mask_, nthreads); #ifdef JFJOCH_USE_CUDA // Built here, once, so the workers' first frames do not queue behind it. if (get_gpu_count() > 0) finder.PrepareDevice(); #endif std::atomic next{0}; std::vector> futures; const size_t nworkers = std::min(nthreads, std::min(PRESCAN_MAX_WORKERS, sample.size())); for (size_t t = 0; t < nworkers; t++) futures.emplace_back(std::async(std::launch::async, [&] { // With a GPU the frame is decoded, preprocessed and analysed on the device - only the // compressed chunk crosses PCIe - and the result is the same to the bit (HotPixelsGPU.h). std::unique_ptr preprocessor; std::unique_ptr image; #ifdef JFJOCH_USE_CUDA std::optional frame; if (get_gpu_count() > 0) { auto stream = std::make_shared(); preprocessor = std::make_unique(experiment_, pixel_mask_, stream, /*copy_image_to_host=*/false); image = std::make_unique(experiment_.GetPixelsNum(), /*host_mirror=*/false); frame.emplace(stream); } #endif if (!preprocessor) { preprocessor = std::make_unique(experiment_, pixel_mask_); image = std::make_unique(experiment_.GetPixelsNum()); } JFJochReaderRawImage raw; std::vector buffer; std::vector scratch; for (size_t i = next.fetch_add(1); i < sample.size(); i = next.fetch_add(1)) { const int image_idx = start_image + sample[i] * config_.stride; try { if (!reader_.ReadRawImage(image_idx, raw)) continue; // As in the image loops (MXAnalysisWithoutFPGA::Analyze): a frame the device cannot // decode goes to the host decoder, so a GPU-side problem costs speed, not the frame. ImageStatistics stats; bool decoded_on_device = false; try { decoded_on_device = preprocessor->AnalyzeCompressed(*image, raw.image, stats); } catch (const JFJochException &e) { cuda_throw_if_context_lost(); logger.Warning("Defective pixels: device decoding of image {} failed ({}), " "decompressing it on the host", image_idx, e.what()); cuda_clear_error(); } if (!decoded_on_device) preprocessor->Analyze(*image, raw.image.GetUncompressedPtr(buffer), raw.image.GetMode()); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Defective pixels: failed to read image {}: {}", image_idx, e.what()); continue; } #ifdef JFJOCH_USE_CUDA if (frame) { finder.AddDeviceImage(image->getGPUBuffer(), *frame); continue; } #endif finder.AddImage(image->data(), scratch); } })); for (auto &f : futures) f.get(); const auto goniometer = experiment_.GetGoniometer(); const double oscillation = std::abs(goniometer->GetIncrement_deg()); const double spacing = sample.size() > 1 ? oscillation * (sample.back() - sample.front()) / static_cast(sample.size() - 1) : 0.0; const auto hot = finder.GetMask(oscillation, spacing, nthreads); if (hot.frames == 0) return; pixel_mask_.LoadHotPixelMask(experiment_, hot.mask); const size_t masked = hot.hot + hot.error + hot.dead; logger.Info("Defective pixels: {} masked from {} pre-scan frames - {} lit above their ring on more " "frames than one reflection or chance explains, {} holding the detector's error value, {} dead", masked, hot.frames, hot.hot, hot.error, hot.dead); Note(fmt::format("{} defective pixel{} masked", masked, masked == 1 ? "" : "s")); }