Files
Jungfraujoch/image_analysis/MXAnalysisWithoutFPGA.cpp
T
leonarski_fandClaude Opus 5 61a7c91b90 Ice: detect it on two channels, and only handle it when it is there
The per-image ice score was read off the PLAIN azimuthal profile. That profile is a
per-ring mean, so a few strong Bragg reflections landing in a ring's q bin lift it
exactly as ice would. Measured over 37 rotation crystals, that did not merely add
noise - it INVERTED the metric: the two highest-scoring crystals had no ice at all
(4.23 and 4.06), while a clean control read 1.57. A decoy null - the identical
statistic evaluated at q positions where hexagonal ice cannot be - reaches 1.51 at its
99th percentile and 2.70 at its maximum, so that metric cannot support any absolute
threshold whatsoever.

The adaptive spot finder already computes the right input for its own threshold: a
sigma-clipped per-resolution-ring background, in the same bins. A powder ring is
azimuthally smooth and survives the clip; Bragg peaks do not. On the clipped profile
the clean population tightens to 1.00-1.22 and the crystals with confirmed ice sit at
2.08-2.37, against a decoy null that never exceeds 1.29.

That channel is blind to one thing: ice in large crystallites diffracts as DISCRETE
spots and leaves the radial profile flat. So a second channel counts found spots on the
rings against the same q width of ice-free flanks beside them. The two barely overlap -
the smooth-ice crystals read 2.1-2.4 / ~1.0 and the textured ones ~1.1 / 3.8-17.6,
while a clean crystal reads 1.04 on both.

Both are then used as a GATE (--ice-min-score 1.5, --ice-min-spot-ratio 2.0, both
calibrated on the battery, 0 disables): the eleven fixed hexagonal bands cover 16-26 %
of the unique reflections at typical resolutions whether or not the crystal has ice, so
flagging, the exclusion from the scale fit and the merge-time CC1/2 ring mask are now
all skipped when neither channel sees any. The gate is applied in the full pipeline and
in --scale, which reads the stored per-image values back out of the _process.h5.

Also fixes the merge-time mask's control: the shoulder now excludes reflections that
are themselves on an ice ring. The rings are not evenly spaced - 1.947/1.916/1.882 A
sit 0.05-0.06 apart in q - so for those three the [w,3w) shoulder landed squarely on
the neighbours and the test compared ice against ice. Measured, that is the only thing
this changes: it removes firings on those three rings and leaves every other firing's
CC pair identical to three decimals.

And the online ice half-width, which was 0.02 in the API against 0.03 offline, so the
same data got a narrower band online than the measured ~0.06 ring FWHM justifies.

Battery (37 rotation crystals, against the previous behaviour): space groups 34/37 in
both and NO crystal's space group changes; 6 crystals gain unique reflections, 1 loses.
Best of them gains 7082 unique reflections with R_meas 16.0 -> 14.3, CC1/2 95.9 -> 97.3
and ISa 13.7 -> 19.0; another goes R_meas 54.9 -> 42.9, CC1/2 84.0 -> 90.4, ISa
3.9 -> 5.5; a third reaches CC1/2 99.4 from 95.7 at an unchanged reflection count. The
one crystal that loses reflections improves on both R_meas and CC1/2.

Not done here: the ScanResult/API/plot-type/frontend/viewer layers for the new
spot_count_ice_control (they need the OpenAPI regeneration). Message, CBOR, HDF5
write/read and the receiver plots are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:17:23 +02:00

320 lines
17 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "MXAnalysisWithoutFPGA.h"
#include <algorithm>
#include <spdlog/spdlog.h>
#include "spot_finding/StrongPixelSet.h"
#include "../compression/JFJochDecompress.h"
#include "spot_finding/SpotUtils.h"
#include "bragg_prediction/BraggPredictionFactory.h"
#include "image_preprocessing/ImagePreprocessorCPU.h"
#include "azint/AzIntEngineCPU.h"
#include "roi/ROIIntegrationCPU.h"
#include "spot_finding/ImageSpotFinderCPU.h"
#include "spot_finding/AdaptiveSpotFinderCPU.h"
#include "bragg_integration/BraggIntegrationEngineCPU.h"
#ifdef JFJOCH_USE_CUDA
#include "azint/AzIntEngineGPU.h"
#include "roi/ROIIntegrationGPU.h"
#include "spot_finding/ImageSpotFinderGPU.h"
#include "spot_finding/AdaptiveSpotFinderGPU.h"
#include "image_preprocessing/ImagePreprocessorGPU.h"
#include "image_preprocessing/ImagePreprocessorBufferGPU.h"
#include "bragg_integration/BraggIntegrationEngineGPU.h"
#include "../common/CUDAWrapper.h"
#endif
MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_experiment,
const AzimuthalIntegrationMapping &in_integration,
const PixelMask &in_mask,
IndexAndRefine &in_indexer,
bool in_enable_fused_adaptive_gpu)
: experiment(in_experiment),
integration(in_integration),
enable_fused_adaptive_gpu(in_enable_fused_adaptive_gpu),
npixels(experiment.GetPixelsNum()),
xpixels(experiment.GetXPixelsNum()),
indexer(in_indexer),
prediction(CreateBraggPrediction(experiment.IsRotationIndexing())),
mask(in_mask),
mask_resolution(experiment.GetPixelsNum(), false),
mask_high_res(-1),
mask_low_res(-1) {
#ifdef JFJOCH_USE_CUDA
if (get_gpu_count() == 0) {
#endif
preprocessor_buffer = std::make_unique<ImagePreprocessorBuffer>(experiment.GetPixelsNum());
spotFinder = std::make_unique<ImageSpotFinderCPU>(experiment.GetXPixelsNum(), experiment.GetYPixelsNum());
azint = std::make_unique<AzIntEngineCPU>(integration);
preprocessor = std::make_unique<ImagePreprocessorCPU>(in_experiment, in_mask);
bragg_engine = std::make_unique<BraggIntegrationEngineCPU>(in_experiment);
if (experiment.ROI().size() >= 1)
roi = std::make_unique<ROIIntegrationCPU>(experiment);
#ifdef JFJOCH_USE_CUDA
} else {
stream = std::make_shared<CudaStream>();
preprocessor_buffer = std::make_unique<ImagePreprocessorBufferGPU>(experiment.GetPixelsNum());
// The preprocessed image only has to come back to the host if a CPU engine reads it. Every
// engine built below runs on the GPU, except the CPU adaptive finder that is kept when the fused
// GPU engine is off - so that is the one case that needs the copy. Every caller currently passes
// enable_fused_adaptive_gpu = true, so on the GPU path the copy is off in practice.
preprocessor = std::make_unique<ImagePreprocessorGPU>(in_experiment, in_mask, stream,
/*copy_image_to_host=*/!enable_fused_adaptive_gpu);
spotFinder = std::make_unique<ImageSpotFinderGPU>(experiment.GetXPixelsNum(), experiment.GetYPixelsNum(), stream);
azint = std::make_unique<AzIntEngineGPU>(integration, stream);
bragg_engine = std::make_unique<BraggIntegrationEngineGPU>(in_experiment, stream);
if (experiment.ROI().size() >= 1)
roi = std::make_unique<ROIIntegrationGPU>(experiment, stream);
if (enable_fused_adaptive_gpu) {
// One GPU engine that computes the azimuthal profile and the adaptive spot mask in a single
// image pass. fused_adaptive aliases it so Analyze() can lift the profile out of it.
auto fused = std::make_unique<AdaptiveSpotFinderGPU>(integration, stream);
fused_adaptive = fused.get();
adaptiveSpotFinder = std::move(fused);
}
}
#endif
if (!adaptiveSpotFinder)
adaptiveSpotFinder = std::make_unique<AdaptiveSpotFinderCPU>(integration);
}
void MXAnalysisWithoutFPGA::Analyze(DataMessage &output,
AzimuthalIntegrationProfile &profile,
const SpotFindingSettings &spot_finding_settings) {
if ((output.image.GetWidth() != xpixels)
|| (output.image.GetWidth() * output.image.GetHeight() != npixels))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Mismatch in pixel size");
// Decompress on the device where the preprocessor can, so only the compressed chunk crosses PCIe
// and the host does no decompression at all. AnalyzeCompressed says whether it took the image;
// when it declines (a CPU preprocessor, or an algorithm with no device decoder) fall through to
// the host route unchanged. The two produce the same preprocessed image.
const auto compression_start_time = std::chrono::steady_clock::now();
ImageStatistics ret{};
bool decoded_on_device = false;
try {
decoded_on_device = preprocessor->AnalyzeCompressed(*preprocessor_buffer, output.image, ret);
} catch (const JFJochException &e) {
// The device route must never be the reason a frame fails: whatever it could not handle, the
// host decoder gets its turn. If the data really is bad the host throws too and the caller
// sees the same error it saw before any of this existed - but a GPU-side problem costs speed
// rather than the acquisition.
spdlog::warn("Device decoding failed ({}), falling back to host decompression", e.what());
decoded_on_device = false;
}
const auto compression_end_time = std::chrono::steady_clock::now();
if (!decoded_on_device) {
const uint8_t *image_ptr = Decompress(output.image);
const auto decompressed_time = std::chrono::steady_clock::now();
if (output.image.GetCompressionAlgorithm() != CompressionAlgorithm::NO_COMPRESSION)
output.compression_time_s = std::chrono::duration<float>(decompressed_time - compression_start_time).count();
const auto preprocessing_start_time = std::chrono::steady_clock::now();
ret = preprocessor->Analyze(*preprocessor_buffer, image_ptr, output.image.GetMode());
const auto preprocessing_end_time = std::chrono::steady_clock::now();
output.preprocessing_time_s = std::chrono::duration<float>(preprocessing_end_time - preprocessing_start_time).count();
} else {
// Decode and preprocess are one device operation here, but the decompression is still a real,
// separately measurable cost - the decoder brackets it with CUDA events - so it is still
// reported as one. Leaving compression_time_s unset instead would blank the broker's
// "compression" plot trace and fill /entry/profiling/compressionTime with NaN.
const float total_s = std::chrono::duration<float>(compression_end_time - compression_start_time).count();
const float decompress_s = std::min(preprocessor->GetLastDecompressionTime_s(), total_s);
output.compression_time_s = decompress_s;
output.preprocessing_time_s = total_s - decompress_s;
}
// The fused GPU engine (rugnux offline, GPU, adaptive detection) produces the azimuthal profile as
// a byproduct of spot finding, so the separate azint pass is skipped in that case and the profile is
// lifted out of the finder below.
const bool fused = enable_fused_adaptive_gpu && spot_finding_settings.enable
&& spot_finding_settings.adaptive_threshold && fused_adaptive != nullptr;
if (!fused) {
const auto azint_start_time = std::chrono::steady_clock::now();
azint->Run(*preprocessor_buffer, profile);
const auto azint_end_time = std::chrono::steady_clock::now();
output.azint_time_s = std::chrono::duration<float>(azint_end_time - azint_start_time).count();
}
if (roi)
roi->Run(*preprocessor_buffer, output.roi);
if (spot_finding_settings.enable) {
// Update resolution mask
if (mask_high_res != spot_finding_settings.high_resolution_limit
|| mask_low_res != spot_finding_settings.low_resolution_limit)
UpdateMaskResolution(spot_finding_settings);
ImageSpotFinder &finder = spot_finding_settings.adaptive_threshold
? static_cast<ImageSpotFinder &>(*adaptiveSpotFinder)
: *spotFinder;
const auto integrate_fn = [this](const std::vector<Reflection> &predicted, size_t npredicted,
int64_t image_number) {
return bragg_engine->Run(*preprocessor_buffer, predicted, npredicted, image_number);
};
// A missing min-pix (std::nullopt) means "choose it per image". This applies only to the stills
// indexing path (each frame is indexed independently); rotation indexing builds one lattice from
// all frames, so it keeps the fixed min-pix and the single-pass finder.
const bool adaptive_min_pix = !spot_finding_settings.min_pix_per_spot.has_value()
&& spot_finding_settings.indexing
&& !experiment.IsRotationIndexing();
if (adaptive_min_pix) {
// Choose the per-image min-pix adaptively instead of a fixed one. min-pix filters connected
// components AFTER detection, so BOTH the detection (the expensive per-pixel pass) and the
// connected-component search run ONCE and only the filter is repeated; the azimuthal
// profile is the one Detect() computed.
// Index at 3/2/1 (index-only, no integration/accumulation), keep whichever maximises
// n_indexed^2 / n_total (indexed count weighted by indexed fraction) together with its spot
// list, and integrate that one.
const auto detect_start_time = std::chrono::steady_clock::now();
finder.Detect(*preprocessor_buffer, spot_finding_settings);
const auto &components = finder.ExtractComponents(*preprocessor_buffer, spot_finding_settings);
float spot_finding_time_s =
std::chrono::duration<float>(std::chrono::steady_clock::now() - detect_start_time).count();
float indexing_time_s = 0.0f;
SpotFindingSettings s = spot_finding_settings;
std::vector<DiffractionSpot> best_spots;
int best_mp = 0;
double best_score = -1.0;
for (int mp : {3, 2, 1}) {
s.min_pix_per_spot = mp;
const auto extract_start_time = std::chrono::steady_clock::now();
std::vector<DiffractionSpot> spots = ImageSpotFinder::Filter(components, s);
spot_finding_time_s +=
std::chrono::duration<float>(std::chrono::steady_clock::now() - extract_start_time).count();
SpotAnalyze(experiment, s, spots, output);
const bool indexed = indexer.IndexFrameOnly(output, s);
indexing_time_s += output.indexing_time_s.value_or(0.0f);
if (indexed) {
const double n_idx = static_cast<double>(output.spot_count_indexed.value_or(0));
const double n_tot = static_cast<double>(std::max<int64_t>(1, output.spot_count.value_or(1)));
const double score = n_idx * n_idx / n_tot;
if (score > best_score) {
best_score = score;
best_mp = mp;
best_spots = std::move(spots);
}
}
}
if (best_mp != 0) {
// Index and integrate the winning spot list; no spot finding left to do.
s.min_pix_per_spot = best_mp;
SpotAnalyze(experiment, s, best_spots, output);
indexer.ProcessImage(output, s, *prediction, integrate_fn);
indexing_time_s += output.indexing_time_s.value_or(0.0f);
}
// Each indexer call reports only its own time, so the escalation's total is summed here.
output.spot_finding_time_s = spot_finding_time_s;
output.indexing_time_s = indexing_time_s;
} else {
const auto spot_finding_start_time = std::chrono::steady_clock::now();
const std::vector<DiffractionSpot> spots = finder.Run(*preprocessor_buffer, spot_finding_settings);
SpotAnalyze(experiment, spot_finding_settings, spots, output);
output.spot_finding_time_s = std::chrono::duration<float>(std::chrono::steady_clock::now() - spot_finding_start_time).count();
if (spot_finding_settings.indexing)
indexer.ProcessImage(output, spot_finding_settings, *prediction, integrate_fn);
}
#ifdef JFJOCH_USE_CUDA
if (fused) {
// Lift the azimuthal profile the fused engine computed in the same detection pass; its azint
// cost is folded into spot_finding_time_s above.
profile.Clear(integration);
profile += fused_adaptive->GetProfile();
output.azint_time_s = 0.0f;
}
#endif
}
output.max_viable_pixel_value = ret.max_value;
output.min_viable_pixel_value = ret.min_value;
output.error_pixel_count = ret.error_pixel_count;
output.saturated_pixel_count = ret.saturated_pixel_count;
output.az_int_profile = profile.GetResult();
output.az_int_profile_count = profile.GetPixelCount();
output.az_int_profile_std = profile.GetStd();
output.bkg_estimate = profile.GetBkgEstimate(integration.Settings());
// The ice score wants a radial profile with the Bragg peaks taken OUT of it. The azimuthal profile
// is a plain per-ring mean, so a strong low-resolution reflection landing in a ring's bin is
// indistinguishable from ice sitting there - measured, that alone lifts clean crystals to a score
// of 1.5-4.2, right into the range real ice occupies. The adaptive spot finder already computes
// exactly what is wanted: a sigma-clipped per-ring background, in the same bins, from which the
// peaks have been removed (an ice ring is azimuthally smooth, so it survives the clip). It is in
// raw counts rather than corrected ones, which the score does not care about - it is a ratio to the
// background interpolated under the ring, and the corrections are smooth in radius.
const std::vector<float> &ring_bkg = adaptiveSpotFinder->GetRingBackground();
const bool have_ring_bkg = spot_finding_settings.enable && spot_finding_settings.adaptive_threshold
&& !ring_bkg.empty();
output.ice_ring_score = AzimuthalIntegrationProfile::IceRingScore(
have_ring_bkg ? ring_bkg : profile.GetResult1D(), integration.GetQBinCount(),
integration.Settings(), spot_finding_settings.ice_ring_width_Q_recipA);
}
void MXAnalysisWithoutFPGA::RebuildROI() {
if (experiment.ROI().empty()) {
roi.reset();
return;
}
#ifdef JFJOCH_USE_CUDA
if (stream) {
roi = std::make_unique<ROIIntegrationGPU>(experiment, stream);
return;
}
#endif
roi = std::make_unique<ROIIntegrationCPU>(experiment);
}
void MXAnalysisWithoutFPGA::AnalyzeROIOnly(DataMessage &output) {
if ((output.image.GetWidth() != xpixels)
|| (output.image.GetWidth() * output.image.GetHeight() != npixels))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Mismatch in pixel size");
const uint8_t *image_ptr = Decompress(output.image);
preprocessor->Analyze(*preprocessor_buffer, image_ptr, output.image.GetMode());
RunROIOnly(output);
}
const uint8_t *MXAnalysisWithoutFPGA::Decompress(const CompressedImage &image) {
// An uncompressed image is read straight out of the message and never touches decompression_buffer,
// so it stays in pageable memory - the buffer is only worth page-locking when it is actually used.
if (image.GetCompressionAlgorithm() != CompressionAlgorithm::NO_COMPRESSION)
preprocessor->PinInputBuffer(decompression_buffer, image.GetUncompressedSize());
return image.GetUncompressedPtr(decompression_buffer);
}
void MXAnalysisWithoutFPGA::RunROIOnly(DataMessage &output) {
output.roi.clear();
if (roi)
roi->Run(*preprocessor_buffer, output.roi);
}
void MXAnalysisWithoutFPGA::UpdateMaskResolution(const SpotFindingSettings &settings) {
mask_low_res = settings.low_resolution_limit;
mask_high_res = settings.high_resolution_limit;
// No high-resolution limit requested -> mask nothing at the high-resolution end: no pixel has d < 0,
// and the detector's own edge is where the pixels stop anyway.
const float high_res = mask_high_res.value_or(0.0f);
auto const &resolution_map = integration.Resolution();
for (int i = 0; i < mask_resolution.size(); i++)
mask_resolution[i] = (resolution_map[i] > mask_low_res) || (resolution_map[i] < high_res);
// The finders keep their own copy (the GPU ones a bit-packed device copy), so the mask is handed
// over here - when the limits change - rather than with every image.
spotFinder->SetResolutionMask(mask_resolution);
adaptiveSpotFinder->SetResolutionMask(mask_resolution);
}