// SPDX-License-Identifier: MPL-2.0 #include #include #include "aare/logger.hpp" #include #include #include #include #include #include #include "test_config.hpp" #include "test_macros.hpp" #include "aare/hist/PixelHistogram.hpp" using aare::NDArray; using aare::NDView; using aare::PixelHistogram; using aare::ssize_t; namespace { // The synchronous fill() has been removed; fill_async() is the only entry // point. This helper submits one frame and blocks until it has been merged // so the tests can keep their straightforward, ordered expectations. } // namespace TEST_CASE("Fill one pixel of a 5x10 histogram") { PixelHistogram hist(5, 10, 20, 0.0f, 10.0f); NDArray image( {5, 10}, -1.0f); // Need to fill with -1 to not generate counts image(2, 3) = 5.7f; // This should go into bin 11 (since bins are [0-0.5), // [0.5-1.0), ..., [9.5-10.0)) // fill_blocking(hist, image.view()); hist.fill_async(NDArray(image)); hist.flush(); // Wait for the async fill to complete before we check the // results auto values = hist.values(); REQUIRE(values.shape(0) == 5); REQUIRE(values.shape(1) == 10); REQUIRE(values.shape(2) == 20); // Check that the correct bin for pixel (2,3) has count 1 CHECK(values(2, 3, 11) == 1); // Check that all other bins are zero for (ssize_t row = 0; row < values.shape(0); ++row) { for (ssize_t col = 0; col < values.shape(1); ++col) { for (ssize_t bin = 0; bin < values.shape(2); ++bin) { if (!(row == 2 && col == 3 && bin == 11)) { CHECK(values(row, col, bin) == 0); } } } } } TEST_CASE("Fill pixels with uneven partial histogram row slices") { PixelHistogram hist(5, 4, 10, 0.0f, 10.0f, 3); NDArray image({5, 4}, -1.0f); image(0, 0) = 0.2f; image(1, 1) = 1.2f; image(2, 2) = 2.2f; image(3, 3) = 3.2f; image(4, 0) = 4.2f; hist.fill_async(NDArray(image)); hist.flush(); auto values = hist.values(); REQUIRE(values.shape(0) == 5); REQUIRE(values.shape(1) == 4); REQUIRE(values.shape(2) == 10); CHECK(values(0, 0, 0) == 1); CHECK(values(1, 1, 1) == 1); CHECK(values(2, 2, 2) == 1); CHECK(values(3, 3, 3) == 1); CHECK(values(4, 0, 4) == 1); for (ssize_t row = 0; row < values.shape(0); ++row) { for (ssize_t col = 0; col < values.shape(1); ++col) { for (ssize_t bin = 0; bin < values.shape(2); ++bin) { const bool expected = (row == 0 && col == 0 && bin == 0) || (row == 1 && col == 1 && bin == 1) || (row == 2 && col == 2 && bin == 2) || (row == 3 && col == 3 && bin == 3) || (row == 4 && col == 0 && bin == 4); if (!expected) { CHECK(values(row, col, bin) == 0); } } } } } TEST_CASE("Row partitioning handles rows < n_threads * ceil(rows/n_threads)") { // Regression test for the pre-existing row partitioning bug: with the // old ceil(rows / n_threads) scheme, rows=17 and n_threads=8 left // trailing threads with negative row counts and threw // "stop >= start required" from boost::histogram during construction. // The current scheme distributes the remainder so every thread gets // at least floor(rows / n_threads) rows. We also exercise the case // where n_threads > rows, which is clamped down to rows. const auto p = GENERATE(table({ {17, 8}, // previously broken: 8 threads * ceil(17/8) = 24 > 17 {17, 5}, // previously broken: 5 threads * ceil(17/5) = 20 > 17 {13, 4}, // previously broken: 4 * ceil(13/4) = 16 > 13 {17, 32}, // n_threads > rows -> clamped to rows {1, 8}, // n_threads > rows -> single thread, single row {6, 6}, // balanced })); const int rows = std::get<0>(p); const int n_threads = std::get<1>(p); const int cols = 3; const int n_bins = 4; constexpr float xmin = 0.0f; constexpr float xmax = 4.0f; PixelHistogram hist(rows, cols, n_bins, xmin, xmax, n_threads); // Put one in-range value per row so we can verify every row is covered. NDArray img({rows, cols}, -1.0f); // -1 is out of range for (ssize_t r = 0; r < rows; ++r) { img(r, 0) = static_cast(r % n_bins) + 0.5f; } hist.fill_async(NDArray(img)); hist.flush(); auto h = hist.values(); REQUIRE(h.shape(0) == rows); REQUIRE(h.shape(1) == cols); REQUIRE(h.shape(2) == n_bins); for (ssize_t r = 0; r < rows; ++r) { const int expected_bin = static_cast(r % n_bins); for (ssize_t c = 0; c < cols; ++c) { for (ssize_t b = 0; b < n_bins; ++b) { const uint16_t want = (c == 0 && b == expected_bin) ? uint16_t{1} : uint16_t{0}; if (h(r, c, b) != want) { INFO("rows=" << rows << " n_threads=" << n_threads << " r=" << r << " c=" << c << " b=" << b << " got=" << h(r, c, b) << " want=" << want); CHECK(h(r, c, b) == want); } } } } } TEST_CASE("Random fills match a reference implementation") { // End-to-end correctness check: compare the implementation against a // simple per-pixel reference for several thread counts. Values are // sampled slightly wider than [xmin, xmax) so the out-of-range filter // is also exercised. constexpr int rows = 17; constexpr int cols = 23; constexpr int n_bins = 32; constexpr float xmin = -1.5f; constexpr float xmax = 4.5f; const int n_threads = GENERATE(1, 2, 4, 8); PixelHistogram hist(rows, cols, n_bins, xmin, xmax, n_threads); std::mt19937 rng(0xC0FFEE); std::uniform_real_distribution dist(xmin - 0.5f, xmax + 0.5f); constexpr int n_frames = 4; std::vector> frames; frames.reserve(n_frames); for (int f = 0; f < n_frames; ++f) { NDArray img({rows, cols}, 0.0f); for (ssize_t r = 0; r < rows; ++r) { for (ssize_t c = 0; c < cols; ++c) { img(r, c) = dist(rng); } } frames.push_back(std::move(img)); } NDArray expected({rows, cols, n_bins}, 0u); const float inv_range = static_cast(n_bins) / (xmax - xmin); for (const auto &img : frames) { for (ssize_t r = 0; r < rows; ++r) { for (ssize_t c = 0; c < cols; ++c) { const float v = img(r, c); if (!(v >= xmin) || !(v < xmax)) continue; int bin = static_cast((v - xmin) * inv_range); if (bin >= n_bins) bin = n_bins - 1; if (bin < 0) bin = 0; expected(r, c, bin) += 1; } } } for (const auto &img : frames) { hist.fill_async(NDArray(img)); } hist.flush(); auto h = hist.values(); REQUIRE(h.shape(0) == rows); REQUIRE(h.shape(1) == cols); REQUIRE(h.shape(2) == n_bins); bool all_match = true; for (ssize_t r = 0; r < rows && all_match; ++r) { for (ssize_t c = 0; c < cols && all_match; ++c) { for (ssize_t b = 0; b < n_bins && all_match; ++b) { if (h(r, c, b) != expected(r, c, b)) { all_match = false; INFO("n_threads=" << n_threads << " r=" << r << " c=" << c << " b=" << b << " got=" << h(r, c, b) << " expected=" << expected(r, c, b)); CHECK(h(r, c, b) == expected(r, c, b)); } } } } CHECK(all_match); } TEST_CASE("fill_async with mismatched shape throws") { PixelHistogram hist(8, 8, 16, 0.0f, 1.0f, 2); NDArray bad({4, 4}, 0.0f); CHECK_THROWS_AS(hist.fill_async(std::move(bad)), std::invalid_argument); } TEST_CASE("Destructor drains pending async fills") { // Submit more frames than the queue can hold so backpressure kicks in, // then immediately let the histogram go out of scope and verify that // the merged values() matches the reference computed sequentially. constexpr int rows = 11; constexpr int cols = 7; constexpr int n_bins = 8; constexpr float xmin = 0.0f; constexpr float xmax = 1.0f; constexpr int n_frames = 12; constexpr std::size_t max_pending = 2; std::vector> frames; frames.reserve(n_frames); std::mt19937 rng(0xDEADBEEF); std::uniform_real_distribution dist(xmin, xmax); for (int f = 0; f < n_frames; ++f) { NDArray img({rows, cols}, 0.0f); for (ssize_t r = 0; r < rows; ++r) for (ssize_t c = 0; c < cols; ++c) img(r, c) = dist(rng); frames.push_back(std::move(img)); } NDArray snapshot({rows, cols, n_bins}, uint16_t{0}); { PixelHistogram hist(rows, cols, n_bins, xmin, xmax, 2, max_pending); for (auto &img : frames) { // Move a copy so we can also build the reference below. NDArray copy({rows, cols}, 0.0f); std::memcpy(copy.data(), img.data(), copy.total_bytes()); hist.fill_async(std::move(copy)); } // No explicit flush(); destructor must drain. // Capture values() *after* the loop but inside the scope so it // observes everything that was submitted (values flushes too). snapshot = hist.values(); } PixelHistogram reference(rows, cols, n_bins, xmin, xmax, 2); for (const auto &img : frames) reference.fill_async(NDArray(img.view())); auto expected = reference.values(); bool all_match = true; for (ssize_t r = 0; r < rows && all_match; ++r) { for (ssize_t c = 0; c < cols && all_match; ++c) { for (ssize_t b = 0; b < n_bins && all_match; ++b) { if (snapshot(r, c, b) != expected(r, c, b)) { all_match = false; INFO("r=" << r << " c=" << c << " b=" << b << " got=" << snapshot(r, c, b) << " expected=" << expected(r, c, b)); CHECK(snapshot(r, c, b) == expected(r, c, b)); } } } } CHECK(all_match); } TEST_CASE("PixelHistogram supports custom storage and axis types") { PixelHistogram hist(2, 2, 4, 0.0, 4.0, 1); NDArray image({2, 2}, -1.0); image(0, 0) = 0.25; image(0, 1) = 1.25; image(1, 0) = 2.25; image(1, 1) = 3.25; hist.fill_async(NDArray(image)); hist.flush(); auto values = hist.values(); STATIC_REQUIRE(std::is_same_v); CHECK(values(0, 0, 0) == uint32_t{1}); CHECK(values(0, 1, 1) == uint32_t{1}); CHECK(values(1, 0, 2) == uint32_t{1}); CHECK(values(1, 1, 3) == uint32_t{1}); auto centers = hist.bin_centers(); STATIC_REQUIRE(std::is_same_v); CHECK(centers.shape(0) == 4); }