Add an opt-in, post-processing limited-response model applied to the finished linear HDR before tone mapping. Each RGB channel is processed independently and isotropically: overflow above E spreads to the eight neighbours with a fixed 9-point stencil, while the rest is absorbed or lost at the image boundary. The synchronous ping-pong update uses a monotonic bounding box and a row-parallel, deterministic reduction; the conservative round bound reserves fp guard rounds inside a 4096 hard limit and fails before touching HDR when exceeded. Expose --sensor-bloom-limit E and --sensor-bloom-transfer e (both required together, default disabled), validate them before expensive initialization, and route every output path through the same hook in write_frame_outputs: raw FITS first, bloom, tone-mapped PNG/PPM, then the mesh overlay. The raw --hdr-output FITS therefore stays pre-bloom. Add a standalone unit test (stencil, boundary loss, cascade reference, symmetry, thread determinism, convergence limits, validation, allocation failure), CLI integration and regression coverage, an isolated sensor-bloom-bench target, and document the model in the design, usage, README, and build docs.
569 lines
23 KiB
C
569 lines
23 KiB
C
/* Regression tests for the standalone sensor-bloom model in
|
|
* src/sensor_bloom.c.
|
|
*
|
|
* These link only the model: no catalog, ray tracing, image writer, FFTW, or
|
|
* GPU is involved, so they build in every ENABLE_HDR/PSF_BACKEND
|
|
* configuration. They call the production entry point and compare it against
|
|
* an independently written dense synchronous reference where a closed form is
|
|
* not simpler. */
|
|
#include "sensor_bloom.h"
|
|
|
|
#include <math.h>
|
|
#include <omp.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/resource.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
/* The scratch malloc-failure path is covered by the ordinary non-ASan run of
|
|
* `allocation_failure_is_reported()` below. AddressSanitizer intercepts
|
|
* allocation and its runtime cannot mmap under a forced RLIMIT_AS, so that
|
|
* probe is skipped in ASan builds rather than being made to pass; the
|
|
* dimension-overflow validation is a separate check and does not substitute
|
|
* for it. */
|
|
#if defined(__SANITIZE_ADDRESS__)
|
|
#define SB_HAVE_ASAN 1
|
|
#elif defined(__has_feature)
|
|
#if __has_feature(address_sanitizer)
|
|
#define SB_HAVE_ASAN 1
|
|
#endif
|
|
#endif
|
|
|
|
static int failures = 0;
|
|
|
|
static void check(int condition, const char *message) {
|
|
if (!condition) {
|
|
fprintf(stderr, "FAIL: %s\n", message);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
static int close_to(double value, double expected, double tolerance) {
|
|
return fabs(value - expected) <= tolerance;
|
|
}
|
|
|
|
static double *alloc_rgb(int width, int height) {
|
|
return calloc((size_t)width * height * 3, sizeof(double));
|
|
}
|
|
|
|
static size_t at(int width, int x, int y, int c) {
|
|
return ((size_t)y * width + x) * 3 + (size_t)c;
|
|
}
|
|
|
|
static double sample(const double *rgb, int width, int x, int y, int c) {
|
|
return rgb[at(width, x, y, c)];
|
|
}
|
|
|
|
static void set(double *rgb, int width, int x, int y, int c, double value) {
|
|
rgb[at(width, x, y, c)] = value;
|
|
}
|
|
|
|
static int all_finite_and_bounded(const double *rgb, size_t count,
|
|
double limit) {
|
|
for (size_t i = 0; i < count; ++i)
|
|
if (!isfinite(rgb[i]) || rgb[i] > limit)
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
/* Independently written dense synchronous reference: every round reads the
|
|
* whole previous state and writes the whole next state, with the same 9-point
|
|
* weights. Only used to cross-check cascades. */
|
|
static double reference_overflow(const double *src, size_t index, double limit) {
|
|
const double difference = src[index] - limit;
|
|
return difference > 0.0 ? difference : 0.0;
|
|
}
|
|
|
|
static void reference_bloom(double *hdr, int width, int height, double limit,
|
|
double transfer) {
|
|
const double axial = 4.0 / 20.0, diagonal = 1.0 / 20.0;
|
|
const double tolerance = 1e-9 * limit;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *first = malloc(count * sizeof *first);
|
|
double *second = malloc(count * sizeof *second);
|
|
memcpy(first, hdr, count * sizeof *first);
|
|
memcpy(second, hdr, count * sizeof *second);
|
|
double *src = first, *dst = second;
|
|
for (int round = 0; round < 20000; ++round) {
|
|
double next_max = 0.0;
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
for (int c = 0; c < 3; ++c) {
|
|
const size_t index = at(width, x, y, c);
|
|
const double value = src[index];
|
|
double incoming = 0.0;
|
|
if (x > 0)
|
|
incoming += axial * reference_overflow(src, at(width, x - 1, y, c), limit);
|
|
if (x + 1 < width)
|
|
incoming += axial * reference_overflow(src, at(width, x + 1, y, c), limit);
|
|
if (y > 0)
|
|
incoming += axial * reference_overflow(src, at(width, x, y - 1, c), limit);
|
|
if (y + 1 < height)
|
|
incoming += axial * reference_overflow(src, at(width, x, y + 1, c), limit);
|
|
if (x > 0 && y > 0)
|
|
incoming += diagonal * reference_overflow(src, at(width, x - 1, y - 1, c), limit);
|
|
if (x + 1 < width && y > 0)
|
|
incoming += diagonal * reference_overflow(src, at(width, x + 1, y - 1, c), limit);
|
|
if (x > 0 && y + 1 < height)
|
|
incoming += diagonal * reference_overflow(src, at(width, x - 1, y + 1, c), limit);
|
|
if (x + 1 < width && y + 1 < height)
|
|
incoming += diagonal * reference_overflow(src, at(width, x + 1, y + 1, c), limit);
|
|
const double next = (value < limit ? value : limit) + transfer * incoming;
|
|
dst[index] = next;
|
|
const double overflow = next - limit;
|
|
if (overflow > next_max)
|
|
next_max = overflow;
|
|
}
|
|
}
|
|
}
|
|
double *swap = src;
|
|
src = dst;
|
|
dst = swap;
|
|
if (next_max <= tolerance)
|
|
break;
|
|
}
|
|
for (size_t i = 0; i < count; ++i)
|
|
if (src[i] > limit)
|
|
src[i] = limit;
|
|
memcpy(hdr, src, count * sizeof *hdr);
|
|
free(first);
|
|
free(second);
|
|
}
|
|
|
|
static void test_no_saturation(void) {
|
|
const int width = 8, height = 6;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
for (size_t i = 0; i < count; ++i)
|
|
hdr[i] = 0.25 * (double)(i % 5);
|
|
double *original = malloc(count * sizeof *original);
|
|
memcpy(original, hdr, count * sizeof *original);
|
|
|
|
const SensorBloomSettings settings = {1.0, 0.5};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"no-saturation apply succeeds");
|
|
check(memcmp(hdr, original, count * sizeof *hdr) == 0,
|
|
"no-saturation buffer is byte-for-byte unchanged");
|
|
check(stats.iterations == 0, "no-saturation uses zero iterations");
|
|
check(stats.predicted_iterations == 0,
|
|
"no-saturation predicts zero iterations");
|
|
check(stats.initially_saturated_channels == 0,
|
|
"no-saturation reports no saturated channel");
|
|
check(stats.final_clamped_channels == 0,
|
|
"no-saturation clamps nothing");
|
|
free(original);
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_zero_transfer(void) {
|
|
const int width = 5, height = 5;
|
|
double *hdr = alloc_rgb(width, height);
|
|
set(hdr, width, 2, 2, 0, 1.7);
|
|
const SensorBloomSettings settings = {1.0, 0.0};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"zero-transfer apply succeeds");
|
|
check(close_to(sample(hdr, width, 2, 2, 0), 1.0, 1e-15),
|
|
"zero-transfer clamps the saturated channel to E");
|
|
check(sample(hdr, width, 1, 2, 0) == 0.0 && sample(hdr, width, 3, 2, 0) == 0.0,
|
|
"zero-transfer leaves axial neighbours unchanged");
|
|
check(sample(hdr, width, 1, 1, 0) == 0.0 && sample(hdr, width, 3, 3, 0) == 0.0,
|
|
"zero-transfer leaves diagonal neighbours unchanged");
|
|
check(close_to(stats.absorbed_signal, 0.7, 1e-15),
|
|
"zero-transfer absorbs the full overflow");
|
|
check(stats.boundary_loss == 0.0, "zero-transfer loses nothing at the boundary");
|
|
check(stats.iterations >= 1, "zero-transfer iterates at least once");
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_single_impulse_stencil(void) {
|
|
const int width = 5, height = 5;
|
|
const double limit = 1.0, transfer = 0.5;
|
|
double *hdr = alloc_rgb(width, height);
|
|
set(hdr, width, 2, 2, 0, 1.8);
|
|
set(hdr, width, 2, 2, 1, 1.5);
|
|
set(hdr, width, 2, 2, 2, 1.2);
|
|
const SensorBloomSettings settings = {limit, transfer};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"single-impulse apply succeeds");
|
|
check(sample(hdr, width, 2, 2, 0) == limit &&
|
|
sample(hdr, width, 2, 2, 1) == limit &&
|
|
sample(hdr, width, 2, 2, 2) == limit,
|
|
"single-impulse centre sits exactly at E");
|
|
const double red_axis = transfer * 0.8 * (4.0 / 20.0);
|
|
const double red_diagonal = transfer * 0.8 * (1.0 / 20.0);
|
|
const double green_axis = transfer * 0.5 * (4.0 / 20.0);
|
|
const double blue_axis = transfer * 0.2 * (4.0 / 20.0);
|
|
check(close_to(sample(hdr, width, 1, 2, 0), red_axis, 1e-15) &&
|
|
close_to(sample(hdr, width, 3, 2, 0), red_axis, 1e-15) &&
|
|
close_to(sample(hdr, width, 2, 1, 0), red_axis, 1e-15) &&
|
|
close_to(sample(hdr, width, 2, 3, 0), red_axis, 1e-15),
|
|
"axial neighbours receive e * D * 4/20");
|
|
check(close_to(sample(hdr, width, 1, 1, 0), red_diagonal, 1e-15) &&
|
|
close_to(sample(hdr, width, 3, 3, 0), red_diagonal, 1e-15),
|
|
"diagonal neighbours receive e * D * 1/20");
|
|
check(close_to(sample(hdr, width, 1, 2, 1), green_axis, 1e-15) &&
|
|
close_to(sample(hdr, width, 1, 2, 2), blue_axis, 1e-15),
|
|
"RGB channels transfer independently");
|
|
check(sample(hdr, width, 0, 0, 0) == 0.0 && sample(hdr, width, 4, 4, 2) == 0.0,
|
|
"far pixels stay untouched");
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_boundary_loss(void) {
|
|
const int width = 5, height = 5;
|
|
const double limit = 1.0, transfer = 0.5;
|
|
double *hdr = alloc_rgb(width, height);
|
|
set(hdr, width, 0, 0, 0, 1.8);
|
|
const SensorBloomSettings settings = {limit, transfer};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"corner apply succeeds");
|
|
const double overflow = 0.8;
|
|
check(close_to(sample(hdr, width, 1, 0, 0), transfer * overflow * 0.2, 1e-15) &&
|
|
close_to(sample(hdr, width, 0, 1, 0), transfer * overflow * 0.2, 1e-15),
|
|
"in-image axial propagation at a corner uses the fixed weight");
|
|
check(close_to(sample(hdr, width, 1, 1, 0), transfer * overflow * 0.05, 1e-15),
|
|
"in-image diagonal propagation at a corner uses the fixed weight");
|
|
check(close_to(stats.boundary_loss,
|
|
transfer * overflow * (2.0 * 0.2 + 3.0 * 0.05), 1e-15),
|
|
"off-image weight is counted as boundary loss without renormalization");
|
|
check(close_to(stats.absorbed_signal, (1.0 - transfer) * overflow, 1e-15),
|
|
"absorbed signal is (1 - e) * D");
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_cascade_against_reference(void) {
|
|
const int width = 9, height = 9;
|
|
const double limit = 0.5, transfer = 0.6;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
set(hdr, width, 4, 4, 0, 10.0);
|
|
set(hdr, width, 4, 4, 1, 4.0);
|
|
set(hdr, width, 2, 6, 2, 3.0);
|
|
set(hdr, width, 7, 1, 0, 1.5);
|
|
double *expected = malloc(count * sizeof *expected);
|
|
memcpy(expected, hdr, count * sizeof *expected);
|
|
reference_bloom(expected, width, height, limit, transfer);
|
|
|
|
const SensorBloomSettings settings = {limit, transfer};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"cascade apply succeeds");
|
|
check(stats.iterations >= 2, "cascade needs at least two rounds");
|
|
double max_difference = 0.0;
|
|
for (size_t i = 0; i < count; ++i)
|
|
max_difference = fmax(max_difference, fabs(hdr[i] - expected[i]));
|
|
check(max_difference < 1e-12, "cascade matches the dense synchronous reference");
|
|
check(all_finite_and_bounded(hdr, count, limit),
|
|
"cascade output is finite and at most E");
|
|
free(expected);
|
|
free(hdr);
|
|
}
|
|
|
|
static void rotate_90(const double *src, double *dst, int size) {
|
|
for (int y = 0; y < size; ++y)
|
|
for (int x = 0; x < size; ++x)
|
|
for (int c = 0; c < 3; ++c)
|
|
set(dst, size, size - 1 - y, x, c, sample(src, size, x, y, c));
|
|
}
|
|
|
|
static void test_symmetry(void) {
|
|
const int size = 9;
|
|
const double limit = 1.0, transfer = 0.5;
|
|
double *hdr = alloc_rgb(size, size);
|
|
set(hdr, size, 4, 4, 0, 10.0);
|
|
const SensorBloomSettings settings = {limit, transfer};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, size, size, &settings, &stats) == 0,
|
|
"symmetry apply succeeds");
|
|
check(sample(hdr, size, 3, 4, 0) == sample(hdr, size, 5, 4, 0) &&
|
|
sample(hdr, size, 3, 4, 0) == sample(hdr, size, 4, 3, 0) &&
|
|
sample(hdr, size, 3, 4, 0) == sample(hdr, size, 4, 5, 0),
|
|
"four axial neighbours of a centred point are equal");
|
|
check(sample(hdr, size, 3, 3, 0) == sample(hdr, size, 5, 3, 0) &&
|
|
sample(hdr, size, 3, 3, 0) == sample(hdr, size, 3, 5, 0) &&
|
|
sample(hdr, size, 3, 3, 0) == sample(hdr, size, 5, 5, 0),
|
|
"four diagonal neighbours of a centred point are equal");
|
|
|
|
/* 90-degree rotational covariance: rotating the input then blooming must
|
|
* equal blooming then rotating the output. This starts from a fresh,
|
|
* asymmetric and still-saturated input; the already-bloomed centre-impulse
|
|
* buffer above has no overflow and would only exercise the no-op path. */
|
|
const size_t count = (size_t)size * size * 3;
|
|
double *input = alloc_rgb(size, size);
|
|
set(input, size, 2, 5, 0, 30.0);
|
|
set(input, size, 6, 3, 1, 8.0);
|
|
set(input, size, 4, 1, 2, 5.0);
|
|
set(input, size, 7, 7, 0, 4.0);
|
|
double *bloomed_input = malloc(count * sizeof *bloomed_input);
|
|
double *rotated_input = alloc_rgb(size, size);
|
|
rotate_90(input, rotated_input, size);
|
|
double *bloomed_rotated = malloc(count * sizeof *bloomed_rotated);
|
|
memcpy(bloomed_input, input, count * sizeof *bloomed_input);
|
|
memcpy(bloomed_rotated, rotated_input, count * sizeof *bloomed_rotated);
|
|
check(sensor_bloom_apply(bloomed_input, size, size, &settings, &stats) == 0 &&
|
|
stats.initially_saturated_channels > 0,
|
|
"covariance input is saturated and blooms");
|
|
check(sensor_bloom_apply(bloomed_rotated, size, size, &settings, &stats) == 0,
|
|
"rotated covariance input blooms");
|
|
double *expected = alloc_rgb(size, size);
|
|
rotate_90(bloomed_input, expected, size);
|
|
double max_difference = 0.0;
|
|
for (size_t i = 0; i < count; ++i)
|
|
max_difference = fmax(max_difference, fabs(bloomed_rotated[i] - expected[i]));
|
|
check(max_difference < 1e-9, "model is 90-degree rotation covariant");
|
|
free(expected);
|
|
free(bloomed_rotated);
|
|
free(rotated_input);
|
|
free(bloomed_input);
|
|
free(input);
|
|
free(hdr);
|
|
}
|
|
|
|
static uint32_t next_random(uint32_t *state) {
|
|
*state = *state * 1664525u + 1013904223u;
|
|
return *state;
|
|
}
|
|
|
|
static void test_thread_determinism(void) {
|
|
const int width = 40, height = 30;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
uint32_t state = 12345u;
|
|
for (size_t i = 0; i < count; ++i)
|
|
hdr[i] = (double)(next_random(&state) % 3000u) / 1000.0;
|
|
double *first = malloc(count * sizeof *first);
|
|
double *second = malloc(count * sizeof *second);
|
|
memcpy(first, hdr, count * sizeof *first);
|
|
memcpy(second, hdr, count * sizeof *second);
|
|
|
|
const SensorBloomSettings settings = {1.5, 0.5};
|
|
SensorBloomStats stats_one, stats_four;
|
|
omp_set_num_threads(1);
|
|
check(sensor_bloom_apply(first, width, height, &settings, &stats_one) == 0,
|
|
"single-thread apply succeeds");
|
|
omp_set_num_threads(4);
|
|
check(sensor_bloom_apply(second, width, height, &settings, &stats_four) == 0,
|
|
"four-thread apply succeeds");
|
|
|
|
check(memcmp(first, second, count * sizeof *first) == 0,
|
|
"final HDR is byte-identical for 1 and 4 threads");
|
|
check(stats_one.absorbed_signal == stats_four.absorbed_signal &&
|
|
stats_one.boundary_loss == stats_four.boundary_loss &&
|
|
stats_one.iterations == stats_four.iterations &&
|
|
stats_one.residual_clamp_loss == stats_four.residual_clamp_loss,
|
|
"reported statistics are identical for 1 and 4 threads");
|
|
free(first);
|
|
free(second);
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_convergence_and_hard_limit(void) {
|
|
const int width = 9, height = 9;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
set(hdr, width, 4, 4, 0, 100.0);
|
|
const SensorBloomSettings settings = {1.0, 0.5};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"converging apply succeeds");
|
|
check(all_finite_and_bounded(hdr, count, 1.0),
|
|
"converged output is finite and at most E");
|
|
check(stats.iterations <= stats.predicted_iterations,
|
|
"actual iterations do not exceed the conservative bound");
|
|
|
|
/* A round bound just inside the hard limit must still converge. The reported
|
|
* cap includes the fp guard but must never exceed 4096, and it must bound the
|
|
* actual number of rounds. */
|
|
const int near_width = 5, near_height = 5;
|
|
const size_t near_count = (size_t)near_width * near_height * 3;
|
|
double *near = alloc_rgb(near_width, near_height);
|
|
set(near, near_width, 2, 2, 0, 1.0 + 7.4e-9);
|
|
const SensorBloomSettings near_settings = {1.0, 0.9995};
|
|
SensorBloomStats near_stats;
|
|
check(sensor_bloom_apply(near, near_width, near_height, &near_settings,
|
|
&near_stats) == 0,
|
|
"near-limit round bound still converges");
|
|
check(near_stats.predicted_iterations <= (size_t)4096,
|
|
"reported round cap never exceeds the 4096 hard limit");
|
|
check(near_stats.iterations <= near_stats.predicted_iterations,
|
|
"actual rounds never exceed the reported guard-inclusive cap");
|
|
check(all_finite_and_bounded(near, near_count, 1.0),
|
|
"near-limit output is finite and at most E");
|
|
free(near);
|
|
|
|
/* A bound one round above the hard limit (4097 with these parameters) must
|
|
* fail in the pre-check, before any HDR sample is touched. */
|
|
double *near_fail = alloc_rgb(near_width, near_height);
|
|
set(near_fail, near_width, 2, 2, 0, 1.0 + 7.758e-9);
|
|
double *near_original = malloc(near_count * sizeof *near_original);
|
|
memcpy(near_original, near_fail, near_count * sizeof *near_original);
|
|
const SensorBloomSettings near_hard = {1.0, 0.9995};
|
|
SensorBloomStats near_hard_stats;
|
|
check(sensor_bloom_apply(near_fail, near_width, near_height, &near_hard,
|
|
&near_hard_stats) == -1,
|
|
"round bound just above the hard limit fails");
|
|
check(memcmp(near_fail, near_original, near_count * sizeof *near_fail) == 0,
|
|
"near-limit failure leaves the buffer byte-for-byte unchanged");
|
|
free(near_original);
|
|
free(near_fail);
|
|
|
|
/* An unbounded-overflow parameter set (e = 1 - 1e-6) needs far more than the
|
|
* 4096-round hard limit and must fail before modifying the input. */
|
|
double *failing = alloc_rgb(width, height);
|
|
set(failing, width, 4, 4, 0, 2.0);
|
|
double *original = malloc(count * sizeof *original);
|
|
memcpy(original, failing, count * sizeof *original);
|
|
const SensorBloomSettings hard = {1.0, 0.999999};
|
|
SensorBloomStats hard_stats;
|
|
check(sensor_bloom_apply(failing, width, height, &hard, &hard_stats) == -1,
|
|
"round bound above the hard limit fails");
|
|
check(memcmp(failing, original, count * sizeof *failing) == 0,
|
|
"hard-limit failure leaves the buffer byte-for-byte unchanged");
|
|
check(hard_stats.iterations == 0,
|
|
"hard-limit failure reports no iterations");
|
|
check(hard_stats.predicted_iterations == 0 &&
|
|
hard_stats.final_clamped_channels == 0,
|
|
"hard-limit failure reports no predicted rounds or clamps");
|
|
free(original);
|
|
free(failing);
|
|
free(hdr);
|
|
}
|
|
|
|
static void test_input_validation(void) {
|
|
const int width = 4, height = 3;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
double *original = malloc(count * sizeof *original);
|
|
const SensorBloomSettings valid = {1.0, 0.5};
|
|
SensorBloomStats stats;
|
|
|
|
check(sensor_bloom_apply(NULL, width, height, &valid, &stats) == -1,
|
|
"NULL framebuffer is rejected");
|
|
check(sensor_bloom_apply(hdr, width, height, NULL, &stats) == -1,
|
|
"NULL settings are rejected");
|
|
check(sensor_bloom_apply(hdr, 0, height, &valid, &stats) == -1,
|
|
"zero width is rejected");
|
|
check(sensor_bloom_apply(hdr, width, -1, &valid, &stats) == -1,
|
|
"negative height is rejected");
|
|
check(sensor_bloom_apply(hdr, width, height, &valid, NULL) == 0,
|
|
"NULL stats are permitted for an unsaturated buffer");
|
|
|
|
double dummy = 0.0;
|
|
check(sensor_bloom_apply(&dummy, INT32_MAX, INT32_MAX, &valid, &stats) == -1,
|
|
"multiplication-overflowing dimensions are rejected before scanning");
|
|
|
|
const double bad_limits[] = {0.0, -1.0, NAN, INFINITY, -INFINITY};
|
|
for (size_t i = 0; i < sizeof bad_limits / sizeof bad_limits[0]; ++i) {
|
|
const SensorBloomSettings bad = {bad_limits[i], 0.5};
|
|
check(sensor_bloom_apply(hdr, width, height, &bad, &stats) == -1,
|
|
"invalid response limit is rejected");
|
|
}
|
|
const double bad_transfers[] = {-0.1, 1.0, 1.5, NAN, INFINITY, -INFINITY};
|
|
for (size_t i = 0; i < sizeof bad_transfers / sizeof bad_transfers[0]; ++i) {
|
|
const SensorBloomSettings bad = {1.0, bad_transfers[i]};
|
|
check(sensor_bloom_apply(hdr, width, height, &bad, &stats) == -1,
|
|
"invalid transfer is rejected");
|
|
}
|
|
|
|
const double non_finite[] = {NAN, INFINITY, -INFINITY};
|
|
for (size_t i = 0; i < sizeof non_finite / sizeof non_finite[0]; ++i) {
|
|
for (size_t j = 0; j < count; ++j)
|
|
hdr[j] = 0.5;
|
|
hdr[count / 2] = non_finite[i];
|
|
memcpy(original, hdr, count * sizeof *original);
|
|
check(sensor_bloom_apply(hdr, width, height, &valid, &stats) == -1,
|
|
"non-finite HDR sample is rejected");
|
|
check(memcmp(hdr, original, count * sizeof *hdr) == 0,
|
|
"non-finite rejection leaves the buffer unchanged");
|
|
}
|
|
free(original);
|
|
free(hdr);
|
|
}
|
|
|
|
#ifndef SB_HAVE_ASAN
|
|
/* Forces the scratch allocation to fail by lowering RLIMIT_AS in a child after
|
|
* the input buffer is already mapped, then checks that the model reports the
|
|
* allocation failure instead of touching the framebuffer. */
|
|
static int allocation_failure_is_reported(void) {
|
|
const pid_t pid = fork();
|
|
if (pid < 0)
|
|
return 0;
|
|
if (pid == 0) {
|
|
const int width = 64, height = 64;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = calloc(count, sizeof *hdr);
|
|
if (hdr == NULL)
|
|
_exit(2);
|
|
hdr[0] = 2.0;
|
|
struct rlimit existing;
|
|
if (getrlimit(RLIMIT_AS, &existing))
|
|
_exit(2);
|
|
const struct rlimit tiny = {.rlim_cur = 1, .rlim_max = existing.rlim_max};
|
|
if (setrlimit(RLIMIT_AS, &tiny))
|
|
_exit(2);
|
|
const SensorBloomSettings settings = {1.0, 0.5};
|
|
SensorBloomStats stats;
|
|
_exit(sensor_bloom_apply(hdr, width, height, &settings, &stats) == -1 ? 0
|
|
: 1);
|
|
}
|
|
int status = 0;
|
|
if (waitpid(pid, &status, 0) < 0)
|
|
return 0;
|
|
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
|
}
|
|
#endif
|
|
|
|
static void test_nonnegative_output(void) {
|
|
const int width = 12, height = 7;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = alloc_rgb(width, height);
|
|
uint32_t state = 99u;
|
|
for (size_t i = 0; i < count; ++i)
|
|
hdr[i] = (double)(next_random(&state) % 4000u) / 1000.0;
|
|
const SensorBloomSettings settings = {1.2, 0.5};
|
|
SensorBloomStats stats;
|
|
check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0,
|
|
"nonnegative apply succeeds");
|
|
int nonnegative = 1;
|
|
for (size_t i = 0; i < count; ++i)
|
|
if (!(hdr[i] >= 0.0) || !isfinite(hdr[i]))
|
|
nonnegative = 0;
|
|
check(nonnegative, "nonnegative input produces nonnegative finite output");
|
|
check(all_finite_and_bounded(hdr, count, 1.2),
|
|
"nonnegative output respects the response limit");
|
|
free(hdr);
|
|
}
|
|
|
|
int main(void) {
|
|
#ifndef SB_HAVE_ASAN
|
|
/* Run the fork-based allocation-failure check before any OpenMP region has
|
|
* been created in the parent. */
|
|
check(allocation_failure_is_reported(),
|
|
"scratch allocation failure is reported without touching the input");
|
|
#endif
|
|
test_no_saturation();
|
|
test_zero_transfer();
|
|
test_single_impulse_stencil();
|
|
test_boundary_loss();
|
|
test_cascade_against_reference();
|
|
test_symmetry();
|
|
test_thread_determinism();
|
|
test_convergence_and_hard_limit();
|
|
test_input_validation();
|
|
test_nonnegative_output();
|
|
|
|
if (failures != 0) {
|
|
fprintf(stderr, "%d sensor-bloom assertion(s) failed\n", failures);
|
|
return EXIT_FAILURE;
|
|
}
|
|
puts("sensor-bloom tests passed");
|
|
return EXIT_SUCCESS;
|
|
}
|