From 4c3a7852e985ea178d35b1ed4bef416c02ed526a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 7 Nov 2026 18:18:07 +0100 Subject: [PATCH] Stream receivers: saturated and error counts on the puller path are the card's In an unsigned image the card writes saturated and error pixels with one value, so the analysis could not tell them apart: JFJochReceiverLite reported card saturated 8, error 8 as saturated 0, error 16. The puller already places the card's module statistics in the message, so the analysis no longer overwrites counts a stream states. A DECTRIS message carries none and is not affected. It is a variant of Analyze the two stream receivers that analyse call, rather than a rule inside Analyze, because Analyze runs again on one message elsewhere (the viewer on a changed mask), where it has to recount. Rotation forwards the message as it came, so it needs nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK --- image_analysis/MXAnalysisWithoutFPGA.cpp | 13 +++++++++ image_analysis/MXAnalysisWithoutFPGA.h | 2 ++ receiver/JFJochReceiverLite.cpp | 2 +- receiver/StillsGPUAnalysis.cpp | 2 +- tests/FPGAImagePullerTest.cpp | 37 +++++++++++++++++++++++- 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/image_analysis/MXAnalysisWithoutFPGA.cpp b/image_analysis/MXAnalysisWithoutFPGA.cpp index 3c228fba2..6a6feb4d2 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.cpp +++ b/image_analysis/MXAnalysisWithoutFPGA.cpp @@ -448,6 +448,19 @@ void MXAnalysisWithoutFPGA::RebuildROI() { roi = std::make_unique(experiment); } +void MXAnalysisWithoutFPGA::AnalyzeStreamImage(DataMessage &output, + AzimuthalIntegrationProfile &profile, + const SpotFindingSettings &spot_finding_settings) { + // In an unsigned image from the card saturated and error pixels have the same value: only the card knows which is which + const auto saturated_pixel_count = output.saturated_pixel_count; + const auto error_pixel_count = output.error_pixel_count; + Analyze(output, profile, spot_finding_settings); + if (saturated_pixel_count) + output.saturated_pixel_count = saturated_pixel_count; + if (error_pixel_count) + output.error_pixel_count = error_pixel_count; +} + void MXAnalysisWithoutFPGA::AnalyzeROIOnly(DataMessage &output) { if ((output.image.GetWidth() != xpixels) || (output.image.GetWidth() * output.image.GetHeight() != npixels)) diff --git a/image_analysis/MXAnalysisWithoutFPGA.h b/image_analysis/MXAnalysisWithoutFPGA.h index 8d3e0708a..d333d6831 100644 --- a/image_analysis/MXAnalysisWithoutFPGA.h +++ b/image_analysis/MXAnalysisWithoutFPGA.h @@ -117,6 +117,8 @@ public: MXAnalysisWithoutFPGA(const DiffractionExperiment &experiment, const AzimuthalIntegrationMapping &integration, const PixelMask &mask, IndexAndRefine &indexer, bool enable_fused_adaptive_gpu = false); void Analyze(DataMessage &output, AzimuthalIntegrationProfile &profile, const SpotFindingSettings &spot_finding_settings); + // Analyze for an image from a stream: saturated and error counts the stream states stay, a DECTRIS message has none. + void AnalyzeStreamImage(DataMessage &output, AzimuthalIntegrationProfile &profile, const SpotFindingSettings &spot_finding_settings); // Surgical ROI-only paths used when a full re-analysis is not wanted: rebuild the // ROI engine after the ROI set changes, recompute ROIs after preprocessing a new diff --git a/receiver/JFJochReceiverLite.cpp b/receiver/JFJochReceiverLite.cpp index a58410c5a..55cb9299c 100644 --- a/receiver/JFJochReceiverLite.cpp +++ b/receiver/JFJochReceiverLite.cpp @@ -342,7 +342,7 @@ void JFJochReceiverLite::DataAnalysisThread(uint32_t id) { AzimuthalIntegrationProfile profile(*az_int_mapping); if (analysis) - analysis->Analyze(data_msg, profile, GetSpotFindingSettings()); + analysis->AnalyzeStreamImage(data_msg, profile, GetSpotFindingSettings()); auto image_end_time = std::chrono::high_resolution_clock::now(); std::chrono::duration image_duration = image_end_time - image_start_time; diff --git a/receiver/StillsGPUAnalysis.cpp b/receiver/StillsGPUAnalysis.cpp index 6011e6aee..3115a2d21 100644 --- a/receiver/StillsGPUAnalysis.cpp +++ b/receiver/StillsGPUAnalysis.cpp @@ -330,7 +330,7 @@ void StillsGPUAnalysis::AnalysisThread() { profile.emplace(*setup.mapping); try { if (analysis) - analysis->Analyze(message, *profile, settings()); + analysis->AnalyzeStreamImage(message, *profile, settings()); } catch (const JFJochException &e) { // The frame is still written, without its analysis. logger.ErrorException(e); diff --git a/tests/FPGAImagePullerTest.cpp b/tests/FPGAImagePullerTest.cpp index 4ff3a8a5a..5d5ee48b0 100644 --- a/tests/FPGAImagePullerTest.cpp +++ b/tests/FPGAImagePullerTest.cpp @@ -142,7 +142,7 @@ namespace { JFJochReceiverOutput output; }; - PathResult Run(const DiffractionExperiment &x, bool puller, int64_t distinct_frames) { + PathResult Run(const DiffractionExperiment &x, bool puller, int64_t distinct_frames, bool rugnux = false) { auto devices = Devices(x); Logger logger("FPGAImagePullerTest"); CollectingPusher pusher; @@ -150,6 +150,7 @@ namespace { service.NumThreads(4); service.SetSpotFindingSettings(Spots()); service.UseFPGAImagePuller(puller); + service.RugnuxLive(rugnux, nullptr, 2, 30); const bool jungfrau = x.GetDetectorType() == DetectorType::JUNGFRAU; for (int64_t f = 0; f < distinct_frames; f++) { @@ -675,6 +676,40 @@ TEST_CASE("FPGAImagePuller_RugnuxReceiver", "[FPGAImagePuller]") { CHECK(a == b); // the third frame in the generator's memory repeats } +// In an unsigned image the card gives saturated and error pixels one value: the counts are the card's, on every receiver. +TEST_CASE("FPGAImagePuller_SaturatedAndErrorCountsFromTheCard", "[FPGAImagePuller]") { + auto x = Base(DetJF(2), 4, 2); + x.Conversion(); + x.Compression(CompressionAlgorithm::BSHUF_LZ4).BitDepthImage(16).PixelSigned(false).FilePrefix("fpga_puller_counts"); + const auto card = Run(x, false, 2); + + PathResult puller; + SECTION("JFJochReceiverLite") { + puller = Run(x, true, 2); + } + SECTION("JFJochReceiverRugnux, stills") { + puller = Run(x, true, 2, true); + } + SECTION("JFJochReceiverRugnux, rotation") { + auto rotation = x; + rotation.Goniometer(GoniometerAxis("omega", 0, 0.1, Coord(-1, 0, 0), std::nullopt)); + puller = Run(rotation, true, 2, true); + } + + REQUIRE(card.images.size() == 4); + REQUIRE(puller.images.size() == 4); + int64_t saturated = 0, error = 0; + for (const auto &[n, image]: puller.images) { + INFO("image " << n); + CHECK(image.message.saturated_pixel_count == card.images.at(n).message.saturated_pixel_count); + CHECK(image.message.error_pixel_count == card.images.at(n).message.error_pixel_count); + saturated += image.message.saturated_pixel_count.value_or(0); + error += image.message.error_pixel_count.value_or(0); + } + CHECK(saturated == 8); + CHECK(error == 8); +} + // Rotation forwards the images unanalysed, so nothing would replace what the card's ROI map, not loaded for this run, gave. TEST_CASE("FPGAImagePuller_NoStaleROI", "[FPGAImagePuller]") { auto x = Base(DetJF(1), 4, 1);