/* 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 #include #include #include #include #include #ifdef ENABLE_PNG #include #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; }