// 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/BackgroundGain.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/ThreadAffinity.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}; // The sample is read once and kept for everything that reads it after the projection - the spot // measurement and the defective pixels used to read it again each, and a CBF, SMV or marCCD frame // is decoded on the host every time it is read. Frames are kept as the reader gives them, // compressed where the file is: a few MB a frame on a 16 Mpx EIGER, 4 bytes a pixel where the // reader decodes. Frames are kept while the kept ones take less than this - so it is passed by at // most a frame per worker - and the rest are read again where they are wanted. constexpr size_t PRESCAN_KEPT_BYTES = size_t{2} << 30; // fn(t) for t < n on threads of their own, thread t on card t % gpus as an image-loop worker is: an // engine built on a worker's card must be used, and freed, with that card current. Which card a // frame goes to changes nothing it gives: the spot finders are exact, and the projection and the // defective-pixel sums are integers, kept per card and added together when they are read. template void OnWorkers(size_t n, Fn &&fn) { std::vector> futures; futures.reserve(n); for (size_t t = 0; t < n; t++) futures.emplace_back(std::async(std::launch::async, [&fn, t] { if (const int32_t gpus = get_gpu_count(); gpus > 0) pin_gpu(static_cast(t) % gpus); fn(t); })); for (auto &f : futures) f.get(); } } 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(); // The background noise per unit level of a CCD, read off the same frames (BackgroundGain.h). const bool want_gain = config_.counts_in_adu && !background_gain_measured_; if (!want_shadow && !want_width && !want_hot && !want_gain) 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"); // What the beam-stop detection reads: the frames, the geometry the rings are drawn in and the mask. // A detection that finds no shadow leaves the mask as it was, so every copy of the run that // pre-scans after it - a probe, a re-check - would detect again on the same inputs; it takes the // answer kept under them instead (BeamStopMemo). std::vector shadow_key; std::optional shadow_memo; { const auto &g = experiment_.GetDiffractionGeometry(); const uint64_t mask_checksum = pixel_mask_.GetBinaryMaskChecksum(); shadow_key = {static_cast(start_image), static_cast(config_.stride), static_cast(experiment_.GetXPixelsNumConv()), static_cast(experiment_.GetYPixelsNumConv()), static_cast(experiment_.IsGeometryTransformed()), static_cast(mask_checksum >> 32), static_cast(mask_checksum & 0xffffffffu), g.GetBeamX_pxl(), g.GetBeamY_pxl(), g.GetDetectorDistance_mm(), g.GetPixelSize_mm(), g.GetWavelength_A(), g.GetPoniRot1_rad(), g.GetPoniRot2_rad(), g.GetPoniRot3_rad(), static_cast(experiment_.GetPolarizationFactor().has_value()), experiment_.GetPolarizationFactor().value_or(0.0f)}; for (const float v : g.GetDetectorMatrix().arr()) shadow_key.push_back(v); shadow_key.insert(shadow_key.end(), shadow_sample.begin(), shadow_sample.end()); } if (want_shadow) { std::lock_guard lock(beam_stop_memo_->m); for (const auto &e : beam_stop_memo_->entries) if (e.key == shadow_key) shadow_memo = e; } const bool find_shadow = want_shadow && !shadow_memo; // The spot measurement, the defective pixels and the background gain are kept the same way, under // the same key and everything else they read: the settings the spots are found with, the // azimuthal binning the adaptive finder thresholds on, and whether the shadow is detected first // (the defective pixels are compared with it masked). A copy of the run that pre-scans again on // the same inputs takes them from here and, where nothing else wants the frames, reads none. std::vector measure_key = shadow_key; { const auto &f = config_.spot_finding; const auto &a = experiment_.GetAzimuthalIntegrationSettings(); measure_key.insert(measure_key.end(), {static_cast(want_shadow), static_cast(experiment_.GetSaturationLimit()), goniometer ? goniometer->GetIncrement_deg() : 0.0, a.GetLowQ_recipA(), a.GetHighQ_recipA(), a.GetQSpacing_recipA(), static_cast(a.GetAzimuthalBinCount()), static_cast(a.IsSolidAngleCorrection()), static_cast(a.IsPolarizationCorrection()), static_cast(f.enable), f.signal_to_noise_threshold, static_cast(f.photon_count_threshold), static_cast(f.min_pix_per_spot.value_or(-1)), static_cast(f.max_pix_per_spot), f.high_resolution_limit.value_or(-1.0f), f.low_resolution_limit.value_or(-1.0f), f.cutoff_spot_count_low_res, f.high_res_gap_Q_recipA.value_or(-1.0f), f.ice_ring_width_Q_recipA, static_cast(f.adaptive_threshold), f.false_pixels_per_frame, static_cast(experiment_.IsDetectIceRings()), static_cast(f.measured_ring_q_recipA.size())}); measure_key.insert(measure_key.end(), f.measured_ring_q_recipA.begin(), f.measured_ring_q_recipA.end()); } std::optional measured; { std::lock_guard lock(prescan_memo_->m); for (const auto &e : prescan_memo_->entries) if (e.key == measure_key) measured = e; } const bool measure_width = want_width && !(measured && measured->spots); const bool measure_hot = want_hot && !(measured && measured->defects); const bool measure_gain = want_gain && !(measured && measured->gain); std::optional finder; if (find_shadow) finder.emplace(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 = measure_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. Of the // preprocessed image only the squares about the spots come back (SPOT_TILE_HALF). 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=*/false); w.preprocessed = std::make_unique(prescan_x.GetPixelsNum(), /*host_mirror=*/false); 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, const 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. // 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). if (for_width) MeasureSpotsOnTiles(*w.preprocessed, static_cast(prescan_x.GetXPixelsNumConv()), static_cast(prescan_x.GetYPixelsNumConv()), prescan_x.GetDiffractionGeometry(), spots, curves, 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())); // Per frame, in sample order, so the gain does not depend on how the workers interleaved. std::vector gain_levels(measure_gain ? ordinals.size() : 0); const size_t gain_frame_stride = (ordinals.size() + background_gain::GAIN_MAX_FRAMES - 1) / background_gain::GAIN_MAX_FRAMES; // The sample as read here, by position, for the spot measurement and the defective pixels below // (PRESCAN_KEPT_BYTES). const bool keep_frames = want_spots || measure_hot; std::vector kept(keep_frames ? ordinals.size() : 0); std::vector is_kept(ordinals.size(), 0); std::atomic kept_bytes{0}; // Frame i of the sample: the one kept, or read into `scratch`. Null where it cannot be read. const auto frame = [&](size_t i, JFJochReaderRawImage &scratch) -> const CompressedImage * { if (is_kept[i]) return &kept[i].image; const int image_idx = start_image + ordinals[i] * config_.stride; try { if (reader_.ReadRawImage(image_idx, scratch)) return &scratch.image; } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Pre-scan: failed to load image {}: {}", image_idx, e.what()); } return nullptr; }; // The spot measurement's engines - the azimuthal mapping its adaptive finder thresholds on, and a // preprocessor and a spot finder per worker - read only the experiment and the mask as they are // now, so they are built while the frames are read rather than after. The mapping is built on the // cores the readers leave free: a frame decoded on the host is on the pre-scan's critical path, and // the mapping is the same whatever number of threads builds it. std::vector workers; std::future engines_built; if (want_spots) { workers.resize(nworkers); engines_built = std::async(std::launch::async, [&] { const size_t free_cpus = std::max(1, AvailableCpus() - std::min(AvailableCpus(), nworkers)); prescan_mapping = std::make_unique(prescan_x, prescan_mask, *azint_geometry_, free_cpus); OnWorkers(nworkers, [&](size_t t) { workers[t] = make_worker(); }); }); } if (find_shadow || measure_gain || keep_frames) { TimingMark mark(logger, "pre-scan read and projection"); std::atomic next{0}; OnWorkers(nworkers, [&](size_t) { std::vector shadow_buffer; std::vector gain_pixels; JFJochReaderRawImage scratch; 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; // Into a slot of its own while there is room to keep it, otherwise into the worker's // buffer, which is reused frame after frame rather than paged in afresh for each. The // reader's own scratch (JFJochReaderRawImage::read_buffer) is not part of the image, so // a kept frame borrows the worker's for the read and gives it back. const bool keep = keep_frames && kept_bytes.load() < PRESCAN_KEPT_BYTES; JFJochReaderRawImage &raw_image = keep ? kept[i] : scratch; if (keep) raw_image.read_buffer = std::move(scratch.read_buffer); 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()); } if (keep) scratch.read_buffer = std::move(raw_image.read_buffer); if (!read) continue; frames_read.fetch_add(1, std::memory_order_relaxed); // Gated on find_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 (find_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); } if (measure_gain && i % gain_frame_stride == 0) { const auto &img = raw_image.image; const size_t n = img.GetWidth() * img.GetHeight(); const uint8_t *ptr = img.GetUncompressedPtr(shadow_buffer); gain_pixels.resize(n); bool measurable = true; switch (img.GetMode()) { case CompressedImageMode::Int32: std::memcpy(gain_pixels.data(), ptr, n * sizeof(int32_t)); break; case CompressedImageMode::Uint16: std::copy_n(reinterpret_cast(ptr), n, gain_pixels.begin()); break; case CompressedImageMode::Int16: std::copy_n(reinterpret_cast(ptr), n, gain_pixels.begin()); break; default: measurable = false; // the CCD readers give 16- or 32-bit integers } if (measurable) gain_levels[i] = background_gain::MeasureFrame(gain_pixels, img.GetWidth(), img.GetHeight(), pixel_mask_.GetMask(), experiment_.GetSaturationLimit()); } if (keep) { kept_bytes.fetch_add(raw_image.image_buffer.capacity()); is_kept[i] = 1; } } }); logger.Debug("Pre-scan: {} of {} frames kept for the steps after the projection, {:.0f} MB", std::count(is_kept.begin(), is_kept.end(), 1), ordinals.size(), kept_bytes.load() / 1e6); } else frames_read = measured ? measured->frames_read : shadow_memo->frames_read; // Applied before anything below copies the integration settings (bragg_before_adaptive_), so every // pass and every fallback integrates with it. At or below 1 - an ADSC or a marCCD, whose pixel // scatters less than its mean - the photon clip is already looser than its nominal width and is left // exactly as it is. if (want_gain) { background_gain_measured_ = true; if (measure_gain) { if (!measured) measured.emplace(); measured->gain = background_gain::Gain(gain_levels); } const std::optional g = *measured->gain; if (g) { BraggIntegrationSettings bis = experiment_.GetBraggIntegrationSettings(); bis.BackgroundPixelGain(std::max(1.0f, *g)); experiment_.ImportBraggIntegrationSettings(bis); } if (!g) logger.Info("Background gain: not measurable on {} frames; the background clip stays the photon " "clip", (ordinals.size() + gain_frame_stride - 1) / gain_frame_stride); else logger.Info("Background gain: a background pixel scatters {:.2f}x its mean in ADU ({} frames){}", *g, (ordinals.size() + gain_frame_stride - 1) / gain_frame_stride, *g > 1.0f ? fmt::format(" => background clip at mean + {:.1f}*sqrt({:.2f}*mean)", experiment_.GetBraggIntegrationSettings().GetBackgroundClipNSigma(), *g) : std::string(" - at or below 1, the photon clip is kept")); } const auto spot_measurement = [&] { TimingMark mark(logger, "pre-scan spot measurement"); engines_built.get(); 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. std::vector visit; std::vector tier_end; visit.reserve(ordinals.size()); { std::vector taken(ordinals.size(), 0); for (const int stride : WIDTH_TIER_STRIDE) { 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()); } } // Where each frame is in that order, and the tier it is in. std::vector position(ordinals.size()); for (size_t v = 0; v < visit.size(); v++) position[visit[v]] = v; const auto tier_of = [&](size_t v) { return static_cast(std::upper_bound(tier_end.begin(), tier_end.end(), v) - tier_end.begin()); }; // The workers go through the frames in that order without waiting at the end of a tier: a frame // is measured the same whichever tier it is read for, so the next tier's frames are started // while the last of this one's finish and the answer below is drawn. What that answer reads is // the frames of the tiers so far and nothing else, and the frames measured past the tier it // stops at are thrown away - so how far ahead the workers got changes nothing. std::mutex m; std::condition_variable cv; std::vector done_in_tier(tier_end.size(), 0); bool failed = false; std::atomic next{0}; std::atomic stop{false}; auto measuring = std::async(std::launch::async, [&] { OnWorkers(nworkers, [&](size_t t) { PreScanWorker &w = workers[t]; try { for (size_t v = next.fetch_add(1); v < visit.size() && !stop.load(); v = next.fetch_add(1)) { const size_t i = visit[v]; const int image_idx = start_image + ordinals[i] * config_.stride; if (const CompressedImage *image = frame(i, w.raw_image)) find_spots(w, *image, image_idx, true, curves_of[i], spot_q_of[i], footprint_of[i]); { std::lock_guard lock(m); done_in_tier[tier_of(v)]++; } cv.notify_all(); } } catch (...) { { std::lock_guard lock(m); failed = true; } cv.notify_all(); throw; } }); }); bool width_settled = false; // 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 = false; std::optional previous_r80; size_t measured_end = 0; // the frames, in visit order, the answer is taken on for (size_t tier = 0; tier < tier_end.size() && !shapes_settled; tier++) { const size_t tier_begin = tier ? tier_end[tier - 1] : 0; { std::unique_lock lock(m); cv.wait(lock, [&] { return failed || done_in_tier[tier] == tier_end[tier] - tier_begin; }); if (failed) break; } measured_end = tier_end[tier]; // A tier begun once the width had settled is read for the bandwidth alone. if (width_settled) for (size_t v = tier_begin; v < measured_end; v++) shape_only[visit[v]] = 1; // 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. std::vector pooled; for (size_t i = 0; i < ordinals.size(); i++) if (position[i] < measured_end) pooled.insert(pooled.end(), curves_of[i].begin(), curves_of[i].end()); if (!width_settled) { width_images = measured_end; width_curves = pooled; const auto r80 = spot_width::R80AtReference(width_curves); width_settled = r80 && previous_r80 && spot_width::WidthSettled(*r80, *previous_r80); previous_r80 = r80; } shape_curves = std::move(pooled); shapes_settled = width_settled && shape_curves.size() >= spot_width::BANDWIDTH_POOL_SPOTS; } stop = true; measuring.get(); OnWorkers(nworkers, [&](size_t t) { PreScanWorker released = std::move(workers[t]); }); for (size_t i = 0; i < ordinals.size(); i++) if (position[i] >= measured_end) { spot_q_of[i].clear(); footprint_of[i].clear(); } 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(); if (want_width && measure_width) { if (!measured) measured.emplace(); measured->spots = PreScanMemo::Spots{width_curves, shape_curves, footprint_spots, prescan_spot_q, width_images}; } else if (want_width) { logger.Info("Spot width: the spots kept from an earlier pre-scan of this run on the same frames, " "geometry, mask and spot finding"); const auto &k = *measured->spots; width_curves = k.width_curves; shape_curves = k.shape_curves; footprint_spots = k.footprint; prescan_spot_q = k.spot_q; width_images = k.width_images; } // 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. std::vector shadow; size_t shadow_frames = 0; if (shadow_memo) { projection = shadow_memo->projection; ring_centre = shadow_memo->ring_centre; shadow.assign(experiment_.GetXPixelsNumConv() * experiment_.GetYPixelsNumConv(), 0); for (const auto &[i, v] : shadow_memo->shadow) shadow[i] = v; shadow_frames = shadow_memo->shadow_frames; logger.Info("{} - kept from an earlier pre-scan of this run on the same frames, geometry and mask", shadow_memo->centre_line); } else { TimingMark mark(logger, "pre-scan beam stop"); std::string centre_line; 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); centre_line = fmt::format("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 { centre_line = fmt::format("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()); } logger.Info("{}", centre_line); shadow = finder->GetMask(config_.nthreads); shadow_frames = finder->GetFrameCount(); finder.reset(); BeamStopMemo::Entry entry{shadow_key, {}, projection, ring_centre, centre_line, frames_read.load(), shadow_frames}; for (size_t i = 0; i < shadow.size(); i++) if (shadow[i] != 0) entry.shadow.emplace_back(static_cast(i), shadow[i]); std::lock_guard lock(beam_stop_memo_->m); auto &entries = beam_stop_memo_->entries; // A handful covers the geometries a run's probes pre-scan at; the oldest go first. if (entries.size() == BeamStopMemo::MAX_ENTRIES) entries.erase(entries.begin()); entries.push_back(std::move(entry)); } 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", shadow_frames); else { logger.Info("Beam stop shadow: {} pixels ({:.2f}% of the detector) found in {} images", shadowed, 100.0 * static_cast(shadowed) / static_cast(shadow.size()), shadow_frames); 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) { if (!measured) measured.emplace(); MaskDefectivePixels(start_image, shadow_sample, ring_centre, frame, measured->defects); } // 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(); if (measured) { measured->key = measure_key; measured->frames_read = frames_read.load(); std::lock_guard lock(prescan_memo_->m); auto &entries = prescan_memo_->entries; std::erase_if(entries, [&](const auto &e) { return e.key == measure_key; }); if (entries.size() == PreScanMemo::MAX_ENTRIES) entries.erase(entries.begin()); entries.push_back(std::move(*measured)); } } void Rugnux::MaskDefectivePixels(int start_image, const std::vector &sample, const std::optional> &ring_centre, const std::function &frame, std::optional &defects) { Logger logger("Rugnux"); TimingMark mark(logger, "pre-scan defective pixels"); hot_pixels_measured_ = true; const auto apply = [&] { if (defects->frames == 0) return; std::vector mask(defects->pixels, 0); for (const auto &[i, v] : defects->mask) mask[i] = v; pixel_mask_.LoadHotPixelMask(experiment_, mask); const size_t masked = defects->hot + defects->error + defects->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, defects->frames, defects->hot, defects->error, defects->dead); Note(fmt::format("{} defective pixel{} masked", masked, masked == 1 ? "" : "s")); }; if (defects) { apply(); return; } // 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 // measures does not exist until every frame of the first read is in, so the frames are gone // through a second time - from memory, as the pre-scan kept them, where it could. 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}; const size_t nworkers = std::min(nthreads, std::min(PRESCAN_MAX_WORKERS, sample.size())); OnWorkers(nworkers, [&](size_t) { // 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 device_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); device_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; const CompressedImage *compressed = frame(i, raw); if (!compressed) continue; try { // 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, *compressed, 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, compressed->GetUncompressedPtr(buffer), compressed->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 (device_frame) { finder.AddDeviceImage(image->getGPUBuffer(), *device_frame); continue; } #endif finder.AddImage(image->data(), scratch); } }); 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); defects = PreScanMemo::Defects{hot.mask.size(), {}, hot.hot, hot.error, hot.dead, hot.frames}; for (size_t i = 0; i < hot.mask.size(); i++) if (hot.mask[i] != 0) defects->mask.emplace_back(static_cast(i), hot.mask[i]); apply(); }