Merge branch 'cpu-fused-pixels' into rc173 (CPU: decode, preprocess and ring statistics fused per bitshuffle block)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
This commit is contained in:
2026-09-28 02:11:35 +02:00
co-authored by Claude Opus 5.5
10 changed files with 332 additions and 150 deletions
+67 -19
View File
@@ -21,13 +21,19 @@ extern "C" {
uint64_t bshuf_read_uint64_BE(const void* buf);
};
inline size_t JFJochDecompressHperfPtr(uint8_t *output,
CompressionAlgorithm algorithm,
const uint8_t *source,
size_t source_size,
size_t nelements,
size_t elem_size,
size_t block_size) {
// Decode the bitshuffle blocks one after another and hand each to block_done(first_element, data,
// nelements), in element order. With output set, each block is decoded in place into output; with
// output = nullptr, into a block-sized buffer that is reused for the next block, so a caller that
// consumes the block straight away reads it from cache and the whole image is never written out.
template <class F>
size_t JFJochDecompressHperfBlocks(uint8_t *output,
CompressionAlgorithm algorithm,
const uint8_t *source,
size_t source_size,
size_t nelements,
size_t elem_size,
size_t block_size,
F &&block_done) {
if ((algorithm != CompressionAlgorithm::BSHUF_LZ4) &&
(algorithm != CompressionAlgorithm::BSHUF_ZSTD) &&
(algorithm != CompressionAlgorithm::BSHUF_ZSTD_RLE) &&
@@ -39,10 +45,11 @@ inline size_t JFJochDecompressHperfPtr(uint8_t *output,
std::vector<char> decompressed_block(block_size * elem_size);
std::vector<char> scratch(block_size * elem_size);
std::vector<uint8_t> unshuffled(output ? 0 : block_size * elem_size);
const uint8_t *src_ptr = source;
const uint8_t *const source_end = source + source_size;
uint8_t *dst_ptr = output;
size_t first_element = 0;
const size_t num_full_blocks = nelements / block_size;
const size_t reminder_size = nelements - num_full_blocks * block_size;
@@ -90,15 +97,17 @@ inline size_t JFJochDecompressHperfPtr(uint8_t *output,
throw JFJochException(JFJochExceptionCategory::Compression, "Algorithm not supported");
}
uint8_t *dst_ptr = output ? output + first_element * elem_size : unshuffled.data();
if (JFJochBitUnshuffleBlock(reinterpret_cast<char *>(dst_ptr),
decompressed_block.data(),
scratch.data(),
current_nelements,
elem_size) < 0)
throw JFJochException(JFJochExceptionCategory::Compression, "bitshuffle block decode error");
block_done(first_element, dst_ptr, current_nelements);
src_ptr += compressed_size;
dst_ptr += decompressed_size;
first_element += decompressed_size / elem_size;
};
for (size_t i = 0; i < num_full_blocks; ++i)
@@ -111,13 +120,26 @@ inline size_t JFJochDecompressHperfPtr(uint8_t *output,
if (leftover_bytes > 0) {
if (static_cast<size_t>(source_end - src_ptr) < leftover_bytes)
throw JFJochException(JFJochExceptionCategory::Compression, "Truncated trailing bytes");
uint8_t *dst_ptr = output ? output + first_element * elem_size : unshuffled.data();
memcpy(dst_ptr, src_ptr, leftover_bytes);
block_done(first_element, dst_ptr, leftover_bytes / elem_size);
src_ptr += leftover_bytes;
}
return static_cast<size_t>(src_ptr - source);
}
inline size_t JFJochDecompressHperfPtr(uint8_t *output,
CompressionAlgorithm algorithm,
const uint8_t *source,
size_t source_size,
size_t nelements,
size_t elem_size,
size_t block_size) {
return JFJochDecompressHperfBlocks(output, algorithm, source, source_size, nelements, elem_size, block_size,
[](size_t, const uint8_t *, size_t) {});
}
// Plain LZ4, HDF5 filter 32004, as DECTRIS Eiger firmware 1.x wrote it. The framing is the same as
// bitshuffle's - a 64-bit big-endian total size, a 32-bit big-endian block size IN BYTES, then each
// block prefixed by its 32-bit big-endian compressed size - and the only difference is that no bit
@@ -166,6 +188,40 @@ inline void JFJochDecompressLZ4Ptr(uint8_t *output,
}
}
// Check the 12-byte bitshuffle header and return the block size (in elements) it gives.
inline size_t JFJochBitshuffleHeaderBlockSize(const uint8_t *source, size_t source_size,
size_t nelements, size_t elem_size) {
// The header must be there before it can be read, and before source_size - 12 is handed to the
// decompressors.
if (source_size < 12)
throw JFJochException(JFJochExceptionCategory::Compression, "Buffer too short for the bitshuffle header");
if (bshuf_read_uint64_BE(const_cast<uint8_t *>(source)) != nelements * elem_size)
throw JFJochException(JFJochExceptionCategory::Compression, "Mismatch in size");
return bshuf_read_uint32_BE(source + 8) / elem_size;
}
inline bool JFJochIsBitshuffle(CompressionAlgorithm algorithm) {
return (algorithm == CompressionAlgorithm::BSHUF_LZ4)
|| (algorithm == CompressionAlgorithm::BSHUF_ZSTD)
|| (algorithm == CompressionAlgorithm::BSHUF_ZSTD_RLE)
|| (algorithm == CompressionAlgorithm::BSHUF_ZSTD_RLE_HUFF);
}
// A bitshuffle-compressed image, handed block by block to block_done(first_element, data, nelements)
// in element order (see JFJochDecompressHperfBlocks), without the image ever being written out whole.
template <class F>
void JFJochDecompressBlocks(CompressionAlgorithm algorithm,
const uint8_t *source,
size_t source_size,
size_t nelements,
size_t elem_size,
F &&block_done) {
const size_t block_size = JFJochBitshuffleHeaderBlockSize(source, source_size, nelements, elem_size);
if (JFJochDecompressHperfBlocks(nullptr, algorithm, source + 12, source_size - 12,
nelements, elem_size, block_size, block_done) != source_size - 12)
throw JFJochException(JFJochExceptionCategory::Compression, "Decompression error");
}
inline void JFJochDecompressPtr(uint8_t *output,
CompressionAlgorithm algorithm,
const uint8_t *source,
@@ -179,16 +235,8 @@ inline void JFJochDecompressPtr(uint8_t *output,
}
size_t block_size = 0;
if (algorithm != CompressionAlgorithm::NO_COMPRESSION) {
// The 12-byte bitshuffle header must be there before it can be read, and before
// source_size - 12 is handed to the decompressors below.
if (source_size < 12)
throw JFJochException(JFJochExceptionCategory::Compression, "Buffer too short for the bitshuffle header");
if (bshuf_read_uint64_BE(const_cast<uint8_t *>(source)) != nelements * elem_size)
throw JFJochException(JFJochExceptionCategory::Compression, "Mismatch in size");
auto tmp = bshuf_read_uint32_BE(source + 8);
block_size = tmp / elem_size;
}
if (algorithm != CompressionAlgorithm::NO_COMPRESSION)
block_size = JFJochBitshuffleHeaderBlockSize(source, source_size, nelements, elem_size);
switch (algorithm) {
case CompressionAlgorithm::NO_COMPRESSION:
+38 -10
View File
@@ -50,7 +50,9 @@ MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_exp
if (get_gpu_count() == 0) {
#endif
preprocessor_buffer = std::make_unique<ImagePreprocessorBuffer>(experiment.GetPixelsNum());
preprocessor = std::make_unique<ImagePreprocessorCPU>(in_experiment, in_mask);
auto cpu_preprocessor = std::make_unique<ImagePreprocessorCPU>(in_experiment, in_mask);
preprocessor_cpu = cpu_preprocessor.get();
preprocessor = std::move(cpu_preprocessor);
bragg_engine = std::make_unique<BraggIntegrationEngineCPU>(in_experiment);
if (experiment.ROI().size() >= 1)
roi = std::make_unique<ROIIntegrationCPU>(experiment);
@@ -128,7 +130,23 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output,
}
const auto compression_end_time = std::chrono::steady_clock::now();
if (!decoded_on_device) {
// 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;
const bool fused_cpu = spot_finding_settings.enable && spot_finding_settings.adaptive_threshold
&& fused_adaptive_cpu != nullptr;
if (preprocessor_cpu) {
const auto preprocessing_start_time = std::chrono::steady_clock::now();
ret = PreprocessCPU(output.image, fused_cpu);
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();
// Decoded inside the preprocessing pass, whose time includes it.
if (output.image.GetCompressionAlgorithm() != CompressionAlgorithm::NO_COMPRESSION)
output.compression_time_s = 0.0f;
} else 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)
@@ -149,14 +167,6 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output,
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;
const bool fused_cpu = spot_finding_settings.enable && spot_finding_settings.adaptive_threshold
&& fused_adaptive_cpu != nullptr;
if (!fused && !fused_cpu) {
const auto azint_start_time = std::chrono::steady_clock::now();
AzInt().Run(*preprocessor_buffer, profile);
@@ -397,6 +407,24 @@ const uint8_t *MXAnalysisWithoutFPGA::Decompress(const CompressedImage &image) {
return image.GetUncompressedPtr(decompression_buffer);
}
ImageStatistics MXAnalysisWithoutFPGA::PreprocessCPU(const CompressedImage &image, bool ring_pass) {
ImageStatistics ret{};
if (ring_pass)
fused_adaptive_cpu->BeginRings();
const auto process_block = [&](size_t first, const uint8_t *data, size_t n) {
preprocessor_cpu->AnalyzeBlock(*preprocessor_buffer, first, data, n, image.GetMode(), ret);
if (ring_pass)
fused_adaptive_cpu->AccumulateRingsBlock(*preprocessor_buffer, first, n);
};
if (JFJochIsBitshuffle(image.GetCompressionAlgorithm()))
JFJochDecompressBlocks(image.GetCompressionAlgorithm(), image.GetCompressed(), image.GetCompressedSize(),
image.GetWidth() * image.GetHeight() * image.GetNumChannels(), image.GetByteDepth(),
process_block);
else
process_block(0, Decompress(image), npixels);
return ret;
}
void MXAnalysisWithoutFPGA::RunROIOnly(DataMessage &output) {
output.roi.clear();
if (roi)
+10
View File
@@ -20,6 +20,7 @@
#include "IndexAndRefine.h"
#include "image_preprocessing/ImagePreprocessor.h"
#include "image_preprocessing/ImagePreprocessorBuffer.h"
#include "image_preprocessing/ImagePreprocessorCPU.h"
class CudaStream;
class AdaptiveSpotFinderGPU;
@@ -32,6 +33,8 @@ class MXAnalysisWithoutFPGA {
std::vector<uint8_t> decompression_buffer;
std::unique_ptr<ImagePreprocessor> preprocessor;
// The preprocessor, where it is the CPU one.
ImagePreprocessorCPU *preprocessor_cpu = nullptr;
size_t npixels;
size_t xpixels;
@@ -68,6 +71,13 @@ class MXAnalysisWithoutFPGA {
// not compressed) and return where it landed.
const uint8_t *Decompress(const CompressedImage &image);
// The CPU preprocessing. A bitshuffled image is decoded a block (~32 KB) at a time and each block
// is preprocessed while it is in cache, then - with ring_pass - put through the adaptive finder's
// plain ring pass as well, so the image is never written out whole before it is preprocessed and
// the preprocessed pixels are read back from cache, not from memory. The blocks come in pixel
// order, so every sum is taken in the same order as by separate passes.
ImageStatistics PreprocessCPU(const CompressedImage &image, bool ring_pass);
// Pixels outside the resolution limits, bit-packed. Built by the integration mapping, which is
// shared by every worker's engine and hands out the same mask to all of them.
std::shared_ptr<const std::vector<uint32_t>> mask_resolution;
@@ -11,57 +11,62 @@ ImagePreprocessorCPU::ImagePreprocessorCPU(const DiffractionExperiment &experime
}
ImageStatistics ImagePreprocessorCPU::Analyze(ImagePreprocessorBuffer &processed_image, const uint8_t *image_ptr, CompressedImageMode image_mode) {
if (processed_image.size() != npixels)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Processed image size mismatch");
ImageStatistics ret{};
AnalyzeBlock(processed_image, 0, image_ptr, npixels, image_mode, ret);
return ret;
}
void ImagePreprocessorCPU::AnalyzeBlock(ImagePreprocessorBuffer &processed_image, size_t first, const uint8_t *input,
size_t n, CompressedImageMode image_mode, ImageStatistics &stats) {
switch (image_mode) {
case CompressedImageMode::Int8:
return Analyze<int8_t>(processed_image, image_ptr, INT8_MIN, INT8_MAX);
return AnalyzeBlock<int8_t>(processed_image, first, input, n, INT8_MIN, INT8_MAX, stats);
case CompressedImageMode::Int16:
return Analyze<int16_t>(processed_image, image_ptr, INT16_MIN, INT16_MAX);
return AnalyzeBlock<int16_t>(processed_image, first, input, n, INT16_MIN, INT16_MAX, stats);
case CompressedImageMode::Int32:
return Analyze<int32_t>(processed_image, image_ptr, INT32_MIN, INT32_MAX);
return AnalyzeBlock<int32_t>(processed_image, first, input, n, INT32_MIN, INT32_MAX, stats);
case CompressedImageMode::Uint8:
return Analyze<uint8_t>(processed_image, image_ptr, UINT8_MAX, UINT8_MAX);
return AnalyzeBlock<uint8_t>(processed_image, first, input, n, UINT8_MAX, UINT8_MAX, stats);
case CompressedImageMode::Uint16:
return Analyze<uint16_t>(processed_image, image_ptr, UINT16_MAX, UINT16_MAX);
return AnalyzeBlock<uint16_t>(processed_image, first, input, n, UINT16_MAX, UINT16_MAX, stats);
case CompressedImageMode::Uint32:
return Analyze<uint32_t>(processed_image, image_ptr, UINT32_MAX, UINT32_MAX);
return AnalyzeBlock<uint32_t>(processed_image, first, input, n, UINT32_MAX, UINT32_MAX, stats);
default:
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "RGB/float mode not supported");
}
}
template<class T>
ImageStatistics ImagePreprocessorCPU::Analyze(ImagePreprocessorBuffer &processed_image, const uint8_t *input, T err_pixel_val, T sat_pixel_val) {
if (processed_image.size() != npixels)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Processed image size mismatch");
void ImagePreprocessorCPU::AnalyzeBlock(ImagePreprocessorBuffer &processed_image, size_t first, const uint8_t *input,
size_t n, T err_pixel_val, T sat_pixel_val, ImageStatistics &ret) {
auto image = reinterpret_cast<const T *>(input);
ImageStatistics ret{};
int32_t *out = processed_image.data() + first;
if (sat_pixel_val > saturation_limit)
sat_pixel_val = static_cast<T>(saturation_limit);
for (int i = 0; i < npixels; i++) {
if (mask_1bit[i] != 0) {
processed_image[i] = INT32_MIN;
for (size_t i = 0; i < n; i++) {
if (mask_1bit[first + i] != 0) {
out[i] = INT32_MIN;
++ret.masked_pixel_count;
} else if (image[i] == err_pixel_val) {
// Error/invalid marker = the pixel type's extreme value (0xFFFFFFFF for EIGER uint32).
// Tested before saturation, since for unsigned types the marker also exceeds sat_pixel_val
// (which is clipped above to the HDF5 saturation_value).
processed_image[i] = INT32_MIN;
out[i] = INT32_MIN;
++ret.error_pixel_count;
} else if (image[i] >= sat_pixel_val) {
processed_image[i] = INT32_MAX;
out[i] = INT32_MAX;
++ret.saturated_pixel_count;
} else {
processed_image[i] = static_cast<int32_t>(image[i]);
out[i] = static_cast<int32_t>(image[i]);
if (image[i] > ret.max_value)
ret.max_value = image[i];
if (image[i] < ret.min_value)
ret.min_value = image[i];
}
}
return ret;
}
@@ -9,8 +9,13 @@ class ImagePreprocessorCPU : public ImagePreprocessor {
std::vector<bool> mask_1bit;
template <class T> ImageStatistics Analyze(ImagePreprocessorBuffer &processed_image, const uint8_t *input, T err_value, T sat_value);
template <class T> void AnalyzeBlock(ImagePreprocessorBuffer &processed_image, size_t first, const uint8_t *input,
size_t n, T err_value, T sat_value, ImageStatistics &stats);
public:
ImagePreprocessorCPU(const DiffractionExperiment &experiment, const PixelMask &mask);
ImageStatistics Analyze(ImagePreprocessorBuffer &processed_image, const uint8_t *decompressed_image, CompressedImageMode image_mode) override;
// Analyze for the n pixels from pixel `first` on, whose values `input` holds, adding to stats. The
// blocks of an image can come in any order and together give Analyze()'s result.
void AnalyzeBlock(ImagePreprocessorBuffer &processed_image, size_t first, const uint8_t *input, size_t n,
CompressedImageMode image_mode, ImageStatistics &stats);
};
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <bitset>
#include <cmath>
#include "AdaptiveSpotFinderCPU.h"
@@ -34,71 +33,87 @@ void AdaptiveSpotFinderCPU::GetProfile(AzimuthalIntegrationProfile &profile) con
// Per-ring background statistics with iterated peak exclusion following peakfinder8:
// Barty et al. (2014) J. Appl. Cryst. 47, 1118-1131
// Accumulate per-ring mean/variance from the raw (photon) image. clip_k <= 0 -> use every valid
// pixel (first pass); clip_k > 0 -> keep only pixels within clip_k sigma of the current ring mean,
// which removes the Bragg peaks from the background estimate.
void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k) {
// Per-ring mean/variance from the raw (photon) image: a plain pass over every valid pixel, then
// sigma-clip passes keeping only pixels within clip_k sigma of the current ring mean, which removes
// the Bragg peaks from the background estimate.
void AdaptiveSpotFinderCPU::ResetRings() {
std::fill(ring_sum.begin(), ring_sum.end(), 0);
std::fill(ring_sum2.begin(), ring_sum2.end(), 0);
std::fill(ring_cnt.begin(), ring_cnt.end(), 0);
std::fill(ring_hist.begin(), ring_hist.end(), 0);
ring_overflow.clear();
if (fuse_azint) {
std::fill(azint_sum.begin(), azint_sum.end(), 0.0f);
std::fill(azint_sum2.begin(), azint_sum2.end(), 0.0f);
std::fill(azint_count.begin(), azint_count.end(), 0);
}
}
void AdaptiveSpotFinderCPU::BeginRings() {
ResetRings();
rings_from_blocks = true;
}
// The plain pass over pixels [first, first + n).
void AdaptiveSpotFinderCPU::AccumulateRingsBlock(const ImagePreprocessorBuffer &image, size_t first, size_t n) {
const auto &pixel_to_bin = mapping.GetPixelToBin();
const size_t nbins = ring_sum.size();
const size_t npix = static_cast<size_t>(width) * height;
const float *corrections = mapping.Corrections().data();
for (size_t pxl = first; pxl < first + n; ++pxl) {
const int32_t v = image[pxl];
if (v == INT32_MIN || v == INT32_MAX) continue; // bad / saturated
const uint16_t b = pixel_to_bin[pxl];
if (b >= nbins) continue; // masked / out of range (UINT16_MAX)
if (fuse_azint) {
const float val = static_cast<float>(v) * corrections[pxl];
const float val_sq = val * val;
azint_sum[b] += val;
azint_sum2[b] += val_sq;
++azint_count[b];
}
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
if (v >= 0 && v < HIST_VALUES)
ring_hist[b * HIST_VALUES + v] += 1;
else
ring_overflow.emplace_back(b, v);
}
}
// A sigma-clip pass over the plain pass's values: each distinct value of a ring meets the same test the
// pixels holding it would, and its pixels are added as a count.
void AdaptiveSpotFinderCPU::ClipRings(float clip_k) {
const size_t nbins = ring_sum.size();
std::fill(ring_sum.begin(), ring_sum.end(), 0);
std::fill(ring_sum2.begin(), ring_sum2.end(), 0);
std::fill(ring_cnt.begin(), ring_cnt.end(), 0);
if (clip_k <= 0.0f) {
std::fill(ring_hist.begin(), ring_hist.end(), 0);
ring_overflow.clear();
const float *corrections = mapping.Corrections().data();
if (fuse_azint) {
std::fill(azint_sum.begin(), azint_sum.end(), 0.0f);
std::fill(azint_sum2.begin(), azint_sum2.end(), 0.0f);
std::fill(azint_count.begin(), azint_count.end(), 0);
}
for (size_t pxl = 0; pxl < npix; ++pxl) {
const int32_t v = image[pxl];
if (v == INT32_MIN || v == INT32_MAX) continue; // bad / saturated
const uint16_t b = pixel_to_bin[pxl];
if (b >= nbins) continue; // masked / out of range (UINT16_MAX)
if (fuse_azint) {
const float val = static_cast<float>(v) * corrections[pxl];
const float val_sq = val * val;
azint_sum[b] += val;
azint_sum2[b] += val_sq;
++azint_count[b];
}
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
if (v >= 0 && v < HIST_VALUES)
ring_hist[b * HIST_VALUES + v] += 1;
else
ring_overflow.emplace_back(b, v);
}
} else {
// A sigma-clip pass over the plain pass's values: each distinct value of a ring meets the same
// test the pixels holding it would, and its pixels are added as a count.
const auto keep = [&](uint16_t b, int32_t v) {
const float lo = ring_mean[b] - clip_k * ring_sigma[b];
const float hi = ring_mean[b] + clip_k * ring_sigma[b];
return !(v < lo || v > hi); // exclude peaks / outliers
};
for (size_t b = 0; b < nbins; ++b)
for (int32_t v = 0; v < HIST_VALUES; ++v) {
const uint32_t n = ring_hist[b * HIST_VALUES + v];
if (n == 0 || !keep(static_cast<uint16_t>(b), v)) continue;
ring_sum[b] += static_cast<int64_t>(n) * v;
ring_sum2[b] += static_cast<uint64_t>(n) * static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += n;
}
for (const auto &[b, v] : ring_overflow) {
if (!keep(b, v)) continue;
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
const auto keep = [&](uint16_t b, int32_t v) {
const float lo = ring_mean[b] - clip_k * ring_sigma[b];
const float hi = ring_mean[b] + clip_k * ring_sigma[b];
return !(v < lo || v > hi); // exclude peaks / outliers
};
for (size_t b = 0; b < nbins; ++b)
for (int32_t v = 0; v < HIST_VALUES; ++v) {
const uint32_t n = ring_hist[b * HIST_VALUES + v];
if (n == 0 || !keep(static_cast<uint16_t>(b), v)) continue;
ring_sum[b] += static_cast<int64_t>(n) * v;
ring_sum2[b] += static_cast<uint64_t>(n) * static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += n;
}
for (const auto &[b, v] : ring_overflow) {
if (!keep(b, v)) continue;
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
}
}
void AdaptiveSpotFinderCPU::UpdateRingStatistics() {
const size_t nbins = ring_sum.size();
for (size_t b = 0; b < nbins; ++b) {
if (ring_cnt[b] > 0) {
const double m = static_cast<double>(ring_sum[b]) / ring_cnt[b];
@@ -114,9 +129,16 @@ void AdaptiveSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image,
const size_t nbins = ring_sum.size();
// --- Stage A: robust per-ring background (one plain pass + two sigma-clip passes) ---
AccumulateRings(image, 0.0f);
AccumulateRings(image, 3.0f);
AccumulateRings(image, 3.0f);
if (!rings_from_blocks) {
ResetRings();
AccumulateRingsBlock(image, 0, static_cast<size_t>(width) * height);
}
rings_from_blocks = false;
UpdateRingStatistics();
ClipRings(3.0f);
UpdateRingStatistics();
ClipRings(3.0f);
UpdateRingStatistics();
// --- Stage B: per-ring threshold from the single portable knob E (false pixels / frame) ---
int64_t n_total = 0;
@@ -153,11 +175,13 @@ void AdaptiveSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image,
: adaptive_threshold::RingThreshold(ring_mean[b], ring_sigma[b], p, z);
// --- Stage C: the ring threshold, intersected with the classic local-box SNR test ---
FlagRings(image);
std::fill(ring_bits.begin(), ring_bits.end(), 0);
if (settings.signal_to_noise_threshold <= 0.0f) {
// No local test asked for: the ring threshold alone decides, as the fixed photon floor
// alone would in the classic finder.
for (int32_t row = 0; row < height; row++)
FlagRow(image, row);
output_buffer = ring_bits;
return;
}
@@ -165,17 +189,17 @@ void AdaptiveSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image,
// The ring threshold IS the photon floor here, so the local pass must not apply another one.
SpotFindingSettings local = settings;
local.photon_count_threshold = 0;
// Only the ring pixels survive the intersection, so the local test is asked of those alone.
DetectAt(image, local, ring_bits);
// Only the ring pixels survive the intersection, so the local test is asked of those alone. They
// are flagged row by row as the local pass reaches each row, which saves reading the image for it.
DetectAt(image, local, ring_bits, [&](int32_t row) { FlagRow(image, row); });
}
void AdaptiveSpotFinderCPU::FlagRings(const ImagePreprocessorBuffer &image) {
void AdaptiveSpotFinderCPU::FlagRow(const ImagePreprocessorBuffer &image, int32_t row) {
const auto &pixel_to_bin = mapping.GetPixelToBin();
const size_t nbins = ring_thr.size();
const size_t npix = static_cast<size_t>(width) * height;
const size_t first = static_cast<size_t>(row) * width;
std::bitset<32> out = 0;
for (size_t pxl = 0; pxl < npix; ++pxl) {
for (size_t pxl = first; pxl < first + width; ++pxl) {
const int32_t v = image[pxl];
const uint16_t b = pixel_to_bin[pxl];
bool strong = false;
@@ -184,14 +208,7 @@ void AdaptiveSpotFinderCPU::FlagRings(const ImagePreprocessorBuffer &image) {
else if (v != INT32_MIN && b < nbins && v >= ring_thr[b])
strong = true;
const int32_t bit = pxl % 32;
if (strong)
out.set(bit);
if (bit == 31) {
ring_bits[pxl / 32] = out.to_ulong();
out.reset();
}
ring_bits[pxl / 32] |= 1U << (pxl % 32);
}
if (npix % 32 != 0)
ring_bits[OutputSize() - 1] = out.to_ulong();
}
@@ -71,7 +71,7 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinderCPU {
std::vector<uint32_t> ring_hist;
std::vector<std::pair<uint16_t, int32_t>> ring_overflow; // (ring, value)
// The azimuthal-integration profile, taken in the plain AccumulateRings pass when asked for: the
// The azimuthal-integration profile, taken in the plain ring pass when asked for: the
// same pixels in the same order and the same arithmetic as AzIntEngineCPU, so the same sums, and
// one pass over the image less (the CPU twin of the fused GPU engine).
bool fuse_azint = false;
@@ -79,15 +79,31 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinderCPU {
std::vector<float> azint_sum2;
std::vector<uint32_t> azint_count;
void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k);
// Fill ring_bits from the thresholds of the current frame.
void FlagRings(const ImagePreprocessorBuffer &image);
// Set by BeginRings(): the plain ring pass of the next Detect() is being accumulated block by block.
bool rings_from_blocks = false;
// Zero the sums of the plain ring pass (and of the fused profile).
void ResetRings();
// One sigma-clip pass over the plain pass's values.
void ClipRings(float clip_k);
// ring_mean / ring_sigma from the current sums.
void UpdateRingStatistics();
// Set the ring_bits of the pixels of one row from the thresholds of the current frame. ring_bits
// is zeroed before the first row.
void FlagRow(const ImagePreprocessorBuffer &image, int32_t row);
public:
explicit AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &mapping);
void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) override;
[[nodiscard]] const std::vector<float> &GetRingBackground() const override { return ring_bkg; }
// The plain ring pass taken while the image is being preprocessed, so the pixels are read while
// still in cache: BeginRings(), then AccumulateRingsBlock() over every block of the image in pixel
// order - the order keeps the profile's float sums the same - and the next Detect() of that image
// starts from these sums instead of passing over the image for them.
void BeginRings();
void AccumulateRingsBlock(const ImagePreprocessorBuffer &image, size_t first, size_t n);
void FuseAzimuthalIntegration(bool enable) { fuse_azint = enable; }
// The profile of the last Detect(), when fused.
void GetProfile(AzimuthalIntegrationProfile &profile) const;
@@ -48,24 +48,12 @@ bool StrongInWindow(int64_t pxl_val, int64_t sum, int64_t sum2, int64_t valid,
} // namespace
void ImageSpotFinderCPU::DetectAt(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings,
const std::vector<uint32_t> &candidates) {
const std::vector<uint32_t> &candidates,
const std::function<void(int32_t)> &fill_row) {
candidate_windows.clear();
// The first pass's bits are read only inside the window of a candidate (and at the candidate), so
// it tests only the pixels of the row/column blocks such a window reaches; the bits it leaves
// unset there are never read.
const int32_t nblocks = (width + 31) / 32;
first_pass_needed.assign(static_cast<size_t>(height) * nblocks, 0);
for (size_t w = 0; w < candidates.size(); w++)
for (uint32_t bits = candidates[w]; bits; bits &= bits - 1) {
const int64_t pxl = static_cast<int64_t>(w) * 32 + std::countr_zero(bits);
if (pxl >= static_cast<int64_t>(width) * height)
break;
const int32_t line = static_cast<int32_t>(pxl / width), col = static_cast<int32_t>(pxl % width);
for (int32_t y = std::max(line - NBX, 0); y <= std::min(line + NBX, height - 1); y++)
for (int32_t b = std::max(col - NBX, 0) / 32; b <= std::min(col + NBX, width - 1) / 32; b++)
first_pass_needed[static_cast<size_t>(y) * nblocks + b] = 1;
}
DetectPass(image, settings, nullptr, first_pass_buffer, candidates.data());
// Filled in by DetectPass as the candidates of each row become known.
first_pass_needed.assign(static_cast<size_t>(height) * ((width + 31) / 32), 0);
DetectPass(image, settings, nullptr, first_pass_buffer, candidates.data(), fill_row);
std::fill(output_buffer.begin(), output_buffer.end(), 0);
const float strong2 = settings.signal_to_noise_threshold * settings.signal_to_noise_threshold;
@@ -107,10 +95,39 @@ void ImageSpotFinderCPU::DetectPass(const ImagePreprocessorBuffer &image,
const SpotFindingSettings &settings,
const uint32_t *prev_strong,
std::vector<uint32_t> &out_buffer,
const uint32_t *candidates) {
const uint32_t *candidates,
const std::function<void(int32_t)> &fill_row) {
for (int i = 0; i < OutputSize(); i++)
out_buffer[i] = 0;
// The first pass's bits are read only inside the window of a candidate (and at the candidate), so
// with candidates it tests only the pixels of the row/column blocks such a window reaches; the bits
// it leaves unset there are never read. Those blocks are marked from each row's candidates as the
// row enters the vertical sums below, which is before any row within NBX of it is tested - so the
// candidates themselves can be filled in a row at a time (fill_row), while the row is in cache.
const int32_t nblocks = (width + 31) / 32;
const auto new_row = [&](int32_t r) {
if (!candidates)
return;
if (fill_row)
fill_row(r);
const int32_t first = r * width;
const int32_t last = first + width - 1;
for (int32_t w = first / 32; w <= last / 32; w++) {
uint32_t bits = candidates[w];
if (w == first / 32)
bits &= UINT32_MAX << (first % 32);
if (w == last / 32 && last % 32 != 31)
bits &= (1U << (last % 32 + 1)) - 1;
for (; bits; bits &= bits - 1) {
const int32_t col = w * 32 + std::countr_zero(bits) - first;
for (int32_t y = std::max(r - NBX, 0); y <= std::min(r + NBX, height - 1); y++)
for (int32_t b = std::max(col - NBX, 0) / 32; b <= std::min(col + NBX, width - 1) / 32; b++)
first_pass_needed[static_cast<size_t>(y) * nblocks + b] = 1;
}
}
};
// A pixel found strong by the previous pass reads as INT32_MAX, which the accumulation below
// already skips and the acceptance test below already takes as strong - the same substitution
// the GPU kernel makes when it reads prev_out.
@@ -147,6 +164,7 @@ void ImageSpotFinderCPU::DetectPass(const ImagePreprocessorBuffer &image,
std::vector<uint16_t> valid_vert(width, 0);
for (int line = 0; line < NBX; line++) {
new_row(line);
for (int col = 0; col < width; col++) {
auto pxl = line * width + col;
@@ -160,6 +178,8 @@ void ImageSpotFinderCPU::DetectPass(const ImagePreprocessorBuffer &image,
}
for (int line = 0; line < height; line++) {
if (line < height - NBX)
new_row(line + NBX);
for (int col = 0; col < width; col++) {
if (line < height - NBX) {
auto pxl = (line + NBX) * width + col;
@@ -188,7 +208,6 @@ void ImageSpotFinderCPU::DetectPass(const ImagePreprocessorBuffer &image,
// candidate lies in one). Each run starts its window from the vertical sums over the
// columns it covers there - integers, so the same sums the running window holds at
// that column - and slides it as below; the bits outside the runs stay 0, as below.
const int32_t nblocks = (width + 31) / 32;
const uint8_t *needed = first_pass_needed.data() + static_cast<size_t>(line) * nblocks;
for (int32_t b = 0; b < nblocks;) {
if (!needed[b]) { b++; continue; }
@@ -5,6 +5,7 @@
#include <cstddef>
#include <functional>
#include <vector>
#include "ImageSpotFinder.h"
@@ -26,10 +27,11 @@ class ImageSpotFinderCPU : public ImageSpotFinder {
// fall into, and reported strong.
//
// With candidates set (SNR test only), the window sums of every candidate pixel are also recorded
// in candidate_windows, in pixel order.
// in candidate_windows, in pixel order, and fill_row (see DetectAt) is called.
void DetectPass(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings,
const uint32_t *prev_strong, std::vector<uint32_t> &out,
const uint32_t *candidates = nullptr);
const uint32_t *candidates = nullptr,
const std::function<void(int32_t)> &fill_row = {});
// Local-box sums (centre pixel included) of one candidate pixel, as the first pass saw them.
struct CandidateWindow {
@@ -47,8 +49,12 @@ protected:
// the image: a candidate's second-pass window is its first-pass window with the pixels the first
// pass found strong taken out, and those are few, so they are subtracted from the sums the first
// pass recorded. Integer sums and the same test, so the bits are exactly Detect's.
//
// fill_row(row), when given, sets the candidates of that row: it is called once for every row, in
// order, before any candidate of the row is read, when the pass is about to read the row anyway.
void DetectAt(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings,
const std::vector<uint32_t> &candidates);
const std::vector<uint32_t> &candidates,
const std::function<void(int32_t)> &fill_row = {});
public:
ImageSpotFinderCPU(int32_t width, int32_t height);
+28
View File
@@ -291,6 +291,34 @@ TEST_CASE("JFJochCompressor_JFJochDecompressor_LZ4","[ZSTD][portable]") {
REQUIRE(memcmp(image.data(), output.data(), x.GetPixelsNum() * sizeof(int32_t)) == 0);
}
TEST_CASE("JFJochDecompressBlocks_matches_JFJochDecompress","[ZSTD]") {
// An element count that leaves a partial block and a few unshuffled trailing elements.
const size_t nelements = 3 * 8192 + 1000 + 5;
std::vector<uint32_t> image(nelements);
std::mt19937 g(1);
std::poisson_distribution<uint32_t> dist(3.0);
for (auto &i: image)
i = dist(g);
for (auto algorithm: {CompressionAlgorithm::BSHUF_LZ4, CompressionAlgorithm::BSHUF_ZSTD}) {
JFJochBitShuffleCompressor compressor(algorithm);
std::vector<char> tmp(nelements * sizeof(uint32_t) * 2 + 12);
tmp.resize(compressor.Compress(tmp.data(), tmp.size(), image));
std::vector<uint32_t> output(nelements, UINT32_MAX);
size_t next = 0;
JFJochDecompressBlocks(algorithm, reinterpret_cast<const uint8_t *>(tmp.data()), tmp.size(),
nelements, sizeof(uint32_t),
[&](size_t first, const uint8_t *data, size_t n) {
REQUIRE(first == next);
memcpy(output.data() + first, data, n * sizeof(uint32_t));
next = first + n;
});
REQUIRE(next == nelements);
REQUIRE(output == image);
}
}
TEST_CASE("JFJochDecompressor_None","[ZSTD]") {
DiffractionExperiment x(DetJF4M());
x.Compression(CompressionAlgorithm::NO_COMPRESSION).BitDepthImage(32).PixelSigned(true);