- Gather the union of every movie frame's all-sky tiles once and read them in bounded batches, replacing the per-frame prefetch scan and log. - Fuse the fast supersampled-buffer clear into the FFTW pack pass so a resolved frame starts clean with no serial memset. - Split parallel HDR->RGB8 tone mapping from PNG encoding. - Add a bounded single-producer/single-writer movie output queue used by both observer movies and multi-frame imported lens maps, with writer timing and error propagation. - Add --png-compression-level and --movie-output-workers shared|reserve-one. - Add --fast-fftw-plan estimate|measure|wisdom|wisdom-update with strict wisdom identity sidecars. - Add staged movie timing, regression tests, and docs.
221 lines
9.4 KiB
C
221 lines
9.4 KiB
C
/* Regression tests for the display tone mapping in src/optics.c.
|
|
*
|
|
* These call the production public functions so the test cannot drift from the
|
|
* implementation; no formula is duplicated here. The test needs neither a
|
|
* catalog, ray tracing, a GPU, nor image files, and it builds in both the
|
|
* ENABLE_PNG=1 and ENABLE_PNG=0 configurations. */
|
|
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "optics.h"
|
|
|
|
#include <math.h>
|
|
#include <setjmp.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
#ifdef ENABLE_PNG
|
|
#include <png.h>
|
|
#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;
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
const double grid[] = {0.0, 1e-12, 1e-9, 1e-6, 1e-3, 0.01, 0.1, 0.25,
|
|
0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 10.0, 1e6};
|
|
const size_t grid_count = sizeof grid / sizeof grid[0];
|
|
const double hardness[] = {1.0, 2.0, 4.0};
|
|
const double small[] = {1e-12, 1e-9, 1e-6};
|
|
const double negative[] = {-1e-12, -0.5, -1.0, -1e6};
|
|
const ToneMapSettings softclip2 = {TONE_MAP_SOFTCLIP, 2.0};
|
|
const ToneMapSettings reinhard = {TONE_MAP_REINHARD, 2.0};
|
|
|
|
/* Basic properties for every supported softclip hardness. */
|
|
for (size_t h = 0; h < 3; ++h) {
|
|
const ToneMapSettings settings = {TONE_MAP_SOFTCLIP, hardness[h]};
|
|
check(tone_map_linear_channel(0.0, &settings) == 0.0,
|
|
"softclip T(0) == 0");
|
|
check(tone_map_linear_channel(INFINITY, &settings) == 1.0,
|
|
"softclip T(+infinity) == 1");
|
|
check(tone_map_linear_channel(1e300, &settings) == 1.0,
|
|
"softclip overflow saturates at 1");
|
|
for (size_t n = 0; n < sizeof negative / sizeof negative[0]; ++n)
|
|
check(tone_map_linear_channel(negative[n], &settings) == 0.0,
|
|
"softclip negative input maps to 0");
|
|
double previous = tone_map_linear_channel(grid[0], &settings);
|
|
for (size_t i = 1; i < grid_count; ++i) {
|
|
const double current = tone_map_linear_channel(grid[i], &settings);
|
|
check(isfinite(current) && current >= 0.0 && current <= 1.0,
|
|
"softclip output is finite and bounded");
|
|
check(current >= previous, "softclip is monotonically non-decreasing");
|
|
previous = current;
|
|
}
|
|
for (size_t i = 0; i < sizeof small / sizeof small[0]; ++i) {
|
|
const double ratio =
|
|
tone_map_linear_channel(small[i], &settings) / small[i];
|
|
check(close_to(ratio, 1.0, 1e-9),
|
|
"softclip T(x)/x tends to 1 for small x");
|
|
}
|
|
}
|
|
|
|
/* Known double-precision values of T_2(x) = sqrt(tanh(x^2)). */
|
|
check(close_to(tone_map_linear_channel(0.5, &softclip2),
|
|
0.49489257663023107, 1e-12), "T2(0.5) value");
|
|
check(close_to(tone_map_linear_channel(1.0, &softclip2),
|
|
0.8726936208978296, 1e-12), "T2(1.0) value");
|
|
check(close_to(tone_map_linear_channel(2.0, &softclip2),
|
|
0.9996645936208139, 1e-12), "T2(2.0) value");
|
|
|
|
/* NULL settings select the production default softclip, p = 2. */
|
|
check(tone_map_linear_channel(0.5, NULL) ==
|
|
tone_map_linear_channel(0.5, &softclip2),
|
|
"NULL settings default to softclip p=2");
|
|
|
|
/* Both operators must sanitize non-finite and nonpositive inputs: the
|
|
* public contract is a finite result in [0, 1] and lround() must never see
|
|
* NaN. This includes Reinhard, whose raw formula would map x < -1 to white
|
|
* and produce NaN for +Infinity or NaN. */
|
|
const ToneMapSettings operators[] = {softclip2, reinhard};
|
|
for (size_t i = 0; i < sizeof operators / sizeof operators[0]; ++i) {
|
|
check(tone_map_linear_channel(NAN, &operators[i]) == 0.0,
|
|
"NaN maps to 0 for every operator");
|
|
check(tone_map_linear_channel(INFINITY, &operators[i]) == 1.0,
|
|
"+infinity maps to 1 for every operator");
|
|
check(tone_map_linear_channel(-2.0, &operators[i]) == 0.0,
|
|
"negative input maps to 0 for every operator");
|
|
check(tone_map_linear_channel(-0.5, &operators[i]) == 0.0,
|
|
"small negative input maps to 0 for every operator");
|
|
check(tone_map_srgb8_channel(NAN, &operators[i]) == 0,
|
|
"NaN 8-bit output is black");
|
|
check(tone_map_srgb8_channel(INFINITY, &operators[i]) == 255,
|
|
"+infinity 8-bit output is white");
|
|
}
|
|
|
|
/* Reinhard must reproduce the pre-soft-clip formula bit for bit. */
|
|
check(tone_map_linear_channel(0.5, &reinhard) == 0.5 / (1.0 + 0.5),
|
|
"reinhard x=0.5 -> 1/3");
|
|
check(tone_map_linear_channel(1.0, &reinhard) == 1.0 / (1.0 + 1.0),
|
|
"reinhard x=1.0 -> 1/2");
|
|
check(tone_map_linear_channel(2.0, &reinhard) == 2.0 / (1.0 + 2.0),
|
|
"reinhard x=2.0 -> 2/3");
|
|
|
|
/* The 8-bit path is what the PNG and PPM writers actually emit. */
|
|
check(tone_map_srgb8_channel(0.0, &softclip2) == 0,
|
|
"softclip p=2 x=0 is black");
|
|
check(tone_map_srgb8_channel(2.0, &softclip2) == 255,
|
|
"softclip p=2 x=2 saturates the white channel");
|
|
check(tone_map_srgb8_channel(2.0, &reinhard) != 255,
|
|
"reinhard x=2 is not fully saturated");
|
|
check(tone_map_srgb8_channel(0.5, &softclip2) >
|
|
tone_map_srgb8_channel(0.5, &reinhard),
|
|
"softclip is brighter than reinhard at x=0.5");
|
|
|
|
/* Parallel HDR -> RGB8 conversion must be byte-identical for any worker
|
|
* count; the display transfer and quantization stay unchanged. */
|
|
{
|
|
const int width = 7, height = 5;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = malloc(count * sizeof *hdr);
|
|
unsigned char *one = malloc(count);
|
|
unsigned char *two = malloc(count);
|
|
unsigned char *four = malloc(count);
|
|
if (hdr == NULL || one == NULL || two == NULL || four == NULL) {
|
|
check(0, "parallel tone-map allocation");
|
|
} else {
|
|
for (size_t i = 0; i < count; ++i)
|
|
hdr[i] = (double)(i % 17) * 0.25 - 0.5;
|
|
check(tone_map_srgb8_image(hdr, one, width, height, &softclip2, 1) == 0,
|
|
"tone_map_srgb8_image serial");
|
|
check(tone_map_srgb8_image(hdr, two, width, height, &softclip2, 2) == 0,
|
|
"tone_map_srgb8_image 2 workers");
|
|
check(tone_map_srgb8_image(hdr, four, width, height, &softclip2, 4) == 0,
|
|
"tone_map_srgb8_image 4 workers");
|
|
check(memcmp(one, two, count) == 0 &&
|
|
memcmp(one, four, count) == 0,
|
|
"parallel tone map changes output bytes");
|
|
for (size_t i = 0; i < count; ++i)
|
|
if (one[i] != tone_map_srgb8_channel(hdr[i], &softclip2)) {
|
|
check(0, "parallel tone map disagrees with channel function");
|
|
break;
|
|
}
|
|
}
|
|
free(hdr);
|
|
free(one);
|
|
free(two);
|
|
free(four);
|
|
}
|
|
|
|
#ifdef ENABLE_PNG
|
|
/* The wrapper's PNG pixels must decode back to the same RGB8 bytes the
|
|
* parallel converter produced. */
|
|
{
|
|
const int width = 9, height = 4;
|
|
const size_t count = (size_t)width * height * 3;
|
|
double *hdr = malloc(count * sizeof *hdr);
|
|
unsigned char *expected = malloc(count);
|
|
unsigned char *decoded = calloc(count, 1);
|
|
char template_path[] = "/tmp/tone_map_XXXXXX";
|
|
char path[64];
|
|
const int fd = mkstemp(template_path);
|
|
snprintf(path, sizeof path, "%s.png", template_path);
|
|
if (hdr == NULL || expected == NULL || decoded == NULL || fd < 0) {
|
|
check(0, "PNG round-trip allocation");
|
|
} else {
|
|
close(fd);
|
|
for (size_t i = 0; i < count; ++i)
|
|
hdr[i] = (double)(i % 23) * 0.11;
|
|
tone_map_srgb8_image(hdr, expected, width, height, &softclip2, 2);
|
|
check(write_tonemapped_image(path, hdr, width, height, &softclip2) == 0,
|
|
"write_tonemapped_image wrapper writes PNG");
|
|
FILE *file = fopen(path, "rb");
|
|
png_structp png = file ? png_create_read_struct(
|
|
PNG_LIBPNG_VER_STRING, NULL, NULL, NULL)
|
|
: NULL;
|
|
png_infop info = png ? png_create_info_struct(png) : NULL;
|
|
volatile int decode_ok = 0;
|
|
if (png == NULL || info == NULL || setjmp(png_jmpbuf(png))) {
|
|
check(0, "PNG decode setup");
|
|
} else {
|
|
png_init_io(png, file);
|
|
png_read_info(png, info);
|
|
for (int row = 0; row < height; ++row)
|
|
png_read_row(png, &decoded[(size_t)row * width * 3], NULL);
|
|
png_read_end(png, info);
|
|
decode_ok = 1;
|
|
}
|
|
if (file) fclose(file);
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
check(decode_ok && memcmp(decoded, expected, count) == 0,
|
|
"wrapper PNG pixels differ from parallel RGB8");
|
|
unlink(path);
|
|
unlink(template_path);
|
|
}
|
|
free(hdr);
|
|
free(expected);
|
|
free(decoded);
|
|
}
|
|
#endif
|
|
|
|
if (failures != 0) {
|
|
fprintf(stderr, "%d tone-map assertion(s) failed\n", failures);
|
|
return EXIT_FAILURE;
|
|
}
|
|
puts("tone-map tests passed");
|
|
return EXIT_SUCCESS;
|
|
}
|