/* Dedicated FFTW-versus-spatial fast-mode convolution regression. * * Both resolvers run on the identical impulse buffer and must agree to double * rounding. The spatial resolver is the production reference, not a fallback. * Compile only for the CPU PSF backend. */ #ifndef FAST_PSF_FFTW #error "test_fast_psf_fftw requires -DFAST_PSF_FFTW (PSF_BACKEND=cpu)" #endif #include "optics.h" #include "fast_psf_fftw.h" #include #include #include #include #include #include #include #ifndef INT_MAX #define INT_MAX 2147483647 #endif static int g_failures = 0; static void fail(const char *what) { fprintf(stderr, "FAIL: %s\n", what); ++g_failures; } static PointSpreadFunction default_psf(void) { PointSpreadFunction psf = {.fwhm_pixels = 2.7, .moffat_beta = 4.5}; return psf; } typedef enum { SCENE_CENTER_WHITE, SCENE_DISTINCT_RGB, SCENE_EDGES, SCENE_BOUNDARY_PHASES, SCENE_MULTIPLE, SCENE_DENSE_CELL, SCENE_RANDOM, SCENE_EMPTY, } Scene; static void fill_scene(FastPsfAccumulator *acc, Scene scene, int width, int height) { const LinearRgb white = {1.0, 1.0, 1.0}; switch (scene) { case SCENE_CENTER_WHITE: fast_psf_accumulator_deposit(acc, 0.5 * width, 0.5 * height, white, 1.0); break; case SCENE_DISTINCT_RGB: fast_psf_accumulator_deposit(acc, 0.5 * width, 0.5 * height, (LinearRgb){1.0, 0.25, 0.05}, 0.75); break; case SCENE_EDGES: fast_psf_accumulator_deposit(acc, 0.3, 0.3, white, 1.0); fast_psf_accumulator_deposit(acc, width - 0.7, 0.4, white, 1.0); fast_psf_accumulator_deposit(acc, 0.5, height - 0.6, white, 1.0); fast_psf_accumulator_deposit(acc, width - 0.4, height - 0.3, white, 1.0); break; case SCENE_BOUNDARY_PHASES: fast_psf_accumulator_deposit(acc, 10.499, 12.499, white, 1.0); fast_psf_accumulator_deposit(acc, 10.501, 12.501, white, 1.0); break; case SCENE_MULTIPLE: fast_psf_accumulator_deposit(acc, 5.5, 5.5, (LinearRgb){1.0, 0.0, 0.0}, 0.5); fast_psf_accumulator_deposit(acc, 12.25, 7.75, (LinearRgb){0.0, 1.0, 0.0}, 0.25); fast_psf_accumulator_deposit(acc, 8.1, 14.9, (LinearRgb){0.0, 0.0, 1.0}, 1.5); break; case SCENE_DENSE_CELL: for (int i = 0; i < 32; ++i) fast_psf_accumulator_deposit(acc, 9.0 + 0.01 * i, 9.0 + 0.013 * i, (LinearRgb){1.0, 0.5, 0.25}, 0.05); break; case SCENE_RANDOM: { uint64_t state = 0x9e3779b97f4a7c15ULL; const size_t count = (size_t)acc->supersampled_width * acc->supersampled_height * 3; for (size_t i = 0; i < count; ++i) { state = state * 6364136223846793005ULL + 1442695040888963407ULL; acc->buffer[i] += (double)(state >> 40) / (double)(1ULL << 24) - 0.5; } break; } case SCENE_EMPTY: break; } } static int run_compare(const char *name, int width, int height, int supersample, FastPsfDeposit deposit, Scene scene, int prefill) { FastPsfAccumulator acc = {0}; const PointSpreadFunction psf = default_psf(); const double relative_tail = 1e-8; if (fast_psf_accumulator_init(&acc, width, height, supersample, deposit, &psf, relative_tail, 0.0, 1)) { fprintf(stderr, "FAIL: %s: accumulator init failed\n", name); ++g_failures; return -1; } if (!acc.fftw_enabled) { fprintf(stderr, "FAIL: %s: FFTW path not enabled\n", name); ++g_failures; fast_psf_accumulator_destroy(&acc); return -1; } const size_t hdr_count = (size_t)width * height * 3; const size_t buffer_count = (size_t)acc.supersampled_width * acc.supersampled_height * 3; double *fftw_hdr = malloc(hdr_count * sizeof *fftw_hdr); double *spatial_hdr = malloc(hdr_count * sizeof *spatial_hdr); double *snapshot = malloc(buffer_count * sizeof *snapshot); if (fftw_hdr == NULL || spatial_hdr == NULL || snapshot == NULL) { fprintf(stderr, "FAIL: %s: HDR allocation failed\n", name); ++g_failures; free(fftw_hdr); free(spatial_hdr); free(snapshot); fast_psf_accumulator_destroy(&acc); return -1; } for (size_t i = 0; i < hdr_count; ++i) fftw_hdr[i] = spatial_hdr[i] = prefill ? 0.25 : 0.0; fill_scene(&acc, scene, width, height); memcpy(snapshot, acc.buffer, buffer_count * sizeof *snapshot); if (fast_psf_accumulator_resolve(&acc, fftw_hdr, 4)) { fprintf(stderr, "FAIL: %s: FFTW resolve failed\n", name); ++g_failures; goto cleanup; } /* The consuming resolve must leave the shared buffer all-zero so the next * frame starts clean. */ for (size_t i = 0; i < buffer_count; ++i) if (acc.buffer[i] != 0.0) { fprintf(stderr, "FAIL: %s: consuming resolve left nonzero residue at %zu\n", name, i); ++g_failures; goto cleanup; } memcpy(acc.buffer, snapshot, buffer_count * sizeof *snapshot); if (fast_psf_accumulator_resolve_spatial_reference(&acc, spatial_hdr, 4)) { fprintf(stderr, "FAIL: %s: spatial reference resolve failed\n", name); ++g_failures; goto cleanup; } if (memcmp(snapshot, acc.buffer, buffer_count * sizeof *snapshot) != 0) { fprintf(stderr, "FAIL: %s: spatial reference resolve mutated the impulse buffer\n", name); ++g_failures; goto cleanup; } double peak = 0.0, max_abs = 0.0, max_rel = 0.0, sum_sq = 0.0; double flux_fftw[3] = {0.0, 0.0, 0.0}; double flux_spatial[3] = {0.0, 0.0, 0.0}; size_t nan_count = 0; for (size_t i = 0; i < hdr_count; ++i) { const double reference = spatial_hdr[i]; const double error = fabs(fftw_hdr[i] - reference); peak = fmax(peak, fabs(reference)); max_abs = fmax(max_abs, error); sum_sq += error * error; if (!isfinite(fftw_hdr[i]) || !isfinite(reference)) ++nan_count; flux_fftw[i % 3] += fftw_hdr[i]; flux_spatial[i % 3] += reference; } /* Mixed absolute/relative acceptance: the absolute floor covers the tiny * Moffat far-wing samples where the FFT and the direct sum disagree only by * roundoff, while the relative term checks significant samples. A crop, * wrap, channel, or normalization bug produces O(1) errors well above both. */ const double rms = sqrt(sum_sq / (double)hdr_count); const double abs_tol = 1e-10 * fmax(1.0, peak); const double rel_tol = 1e-9; const double rel_floor = 1e-6 * fmax(peak, 1.0); double max_violation = 0.0; for (size_t i = 0; i < hdr_count; ++i) { const double reference = spatial_hdr[i]; const double error = fabs(fftw_hdr[i] - reference); max_violation = fmax(max_violation, error - (abs_tol + rel_tol * fabs(reference))); if (fabs(reference) > rel_floor) max_rel = fmax(max_rel, error / fabs(reference)); } int worst = -1; double worst_error = 0.0; for (size_t i = 0; i < hdr_count; ++i) { const double error = fabs(fftw_hdr[i] - spatial_hdr[i]); if (error > worst_error) { worst_error = error; worst = (int)(i / 3); } } double flux_rel = 0.0; for (int c = 0; c < 3; ++c) { const double denom = fmax(fabs(flux_spatial[c]), 1e-30); flux_rel = fmax(flux_rel, fabs(flux_fftw[c] - flux_spatial[c]) / denom); } if (nan_count != 0 || max_violation > 0.0 || max_rel > rel_tol || flux_rel > 1e-10) { fprintf(stderr, "FAIL: %s: peak=%.6g max_abs=%.3g (tol %.3g) max_rel=%.3g " "rms=%.3g flux_rel=%.3g nan=%zu worst_pixel=%d\n", name, peak, max_abs, abs_tol, max_rel, rms, flux_rel, nan_count, worst); ++g_failures; } else { printf("ok %-28s peak=%.4g max_abs=%.3g max_rel=%.3g rms=%.3g\n", name, peak, max_abs, max_rel, rms); } cleanup: free(fftw_hdr); free(spatial_hdr); free(snapshot); fast_psf_accumulator_destroy(&acc); return g_failures == 0 ? 0 : -1; } static void test_next_smooth_size(void) { struct { size_t input; long expected; /* -1 = must fail */ } cases[] = { {1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}, {6, 6}, {7, 7}, {8, 8}, {9, 9}, {11, 12}, {13, 14}, {15, 15}, {121, 125}, {127, 128}, {200, 200}, {241, 243}, {(size_t)INT_MAX + 1, -1}, {0, -1}, }; for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) { size_t out = 0; const int rc = fast_psf_fftw_next_smooth_size(cases[i].input, &out); if (cases[i].expected < 0) { if (rc == 0) fprintf(stderr, "FAIL: next_smooth_size(%zu) unexpectedly returned %zu\n", cases[i].input, out), ++g_failures; } else if (rc != 0 || out != (size_t)cases[i].expected) { fprintf(stderr, "FAIL: next_smooth_size(%zu) = rc %d, %zu (want %ld)\n", cases[i].input, rc, out, cases[i].expected); ++g_failures; } } size_t out = 0; if (fast_psf_fftw_next_smooth_size((size_t)1 << 30, &out) != 0 || out > (size_t)INT_MAX) fail("next_smooth_size near INT_MAX did not return a valid FFT size"); } /* A single impulse must not wrap to the opposite edge, and an empty buffer must * leave the framebuffer untouched. */ static void test_no_wraparound_and_empty(void) { PointSpreadFunction psf = default_psf(); /* A compact kernel keeps the image much wider than the support so a genuine * circular-wrap bug would show up as an opposite-edge ghost. */ psf.fwhm_pixels = 0.02; const double relative_tail = 1e-8; const int width = 12, height = 12, supersample = 2; FastPsfAccumulator acc = {0}; if (fast_psf_accumulator_init(&acc, width, height, supersample, FAST_PSF_DEPOSIT_NEAREST, &psf, relative_tail, 0.0, 1)) { fail("wraparound accumulator init"); return; } const size_t count = (size_t)width * height * 3; double *hdr = calloc(count, sizeof *hdr); if (hdr == NULL) { fail("wraparound allocation"); fast_psf_accumulator_destroy(&acc); return; } fast_psf_accumulator_deposit(&acc, 0.6, 0.6, (LinearRgb){1.0, 1.0, 1.0}, 1.0); if (fast_psf_accumulator_resolve(&acc, hdr, 2)) { fail("wraparound resolve"); } else { const int far_x = width - 1, far_y = height - 1; const double ghost = hdr[3 * (far_y * width + far_x)]; if (fabs(ghost) > 1e-15) fprintf(stderr, "FAIL: opposite-edge ghost value %.3g\n", ghost), ++g_failures; if (hdr[0] <= 0.0) fail("impulse peak missing near the deposited corner"); } /* Empty buffer: the same accumulator, after clearing, must not change HDR. */ fast_psf_accumulator_clear(&acc); double background[3] = {0.25, 0.5, 0.75}; for (size_t i = 0; i < count; ++i) hdr[i] = background[i % 3]; if (fast_psf_accumulator_resolve(&acc, hdr, 2)) fail("empty resolve"); for (size_t i = 0; i < count; ++i) { if (hdr[i] != background[i % 3]) { fail("empty input changed the HDR framebuffer"); break; } } free(hdr); fast_psf_accumulator_destroy(&acc); } /* Channel separation: a pure-red impulse must leave green and blue at zero. */ static void test_channel_isolation(void) { const PointSpreadFunction psf = default_psf(); const int width = 20, height = 18; FastPsfAccumulator acc = {0}; if (fast_psf_accumulator_init(&acc, width, height, 2, FAST_PSF_DEPOSIT_NEAREST, &psf, 1e-8, 0.0, 1)) { fail("channel isolation init"); return; } const size_t count = (size_t)width * height * 3; double *hdr = calloc(count, sizeof *hdr); fast_psf_accumulator_deposit(&acc, 10.0, 9.0, (LinearRgb){1.0, 0.0, 0.0}, 1.0); if (fast_psf_accumulator_resolve(&acc, hdr, 2)) fail("channel isolation resolve"); for (size_t i = 0; i < count; i += 3) { if (hdr[i + 1] != 0.0 || hdr[i + 2] != 0.0) { fail("green/blue channel leaked into a pure-red impulse"); break; } } free(hdr); fast_psf_accumulator_destroy(&acc); } /* Reusing one accumulator across frames must never leak frame N-1 deposits * into frame N. Frame 1 deposits an impulse; frame 2 deposits nothing and * must resolve to all zeros. A third frame with a different impulse must not * contain the first impulse. */ static void test_cross_frame_no_residue(FastPsfDeposit deposit) { const PointSpreadFunction psf = default_psf(); const double relative_tail = 1e-8; const int width = 19, height = 15, supersample = 2; FastPsfAccumulator acc = {0}; if (fast_psf_accumulator_init(&acc, width, height, supersample, deposit, &psf, relative_tail, 0.0, 1)) { fail("cross-frame init"); return; } const size_t count = (size_t)width * height * 3; double *hdr = calloc(count, sizeof *hdr); double *reference = calloc(count, sizeof *reference); if (hdr == NULL || reference == NULL) { fail("cross-frame allocation"); free(hdr); free(reference); fast_psf_accumulator_destroy(&acc); return; } const LinearRgb white = {1.0, 1.0, 1.0}; /* Frame 1: impulse at a known pixel. */ fast_psf_accumulator_deposit(&acc, 4.5, 4.5, white, 1.0); if (fast_psf_accumulator_resolve(&acc, hdr, 2)) fail("cross-frame frame 1 resolve"); if (!(hdr[3 * (4 * width + 4)] > 0.0)) fail("cross-frame frame 1 peak missing"); /* Frame 2: no deposit; every output channel must be exactly zero. */ memset(hdr, 0, count * sizeof *hdr); if (fast_psf_accumulator_resolve(&acc, hdr, 2)) fail("cross-frame frame 2 resolve"); for (size_t i = 0; i < count; ++i) if (hdr[i] != 0.0) { fail("cross-frame frame 2 inherited stale deposits"); break; } /* Frame 3: a different impulse. Its result must equal a freshly built * accumulator given only that impulse, proving frame 1 left no residue. */ FastPsfAccumulator fresh = {0}; if (fast_psf_accumulator_init(&fresh, width, height, supersample, deposit, &psf, relative_tail, 0.0, 1)) { fail("cross-frame reference init"); free(hdr); free(reference); fast_psf_accumulator_destroy(&acc); return; } memset(hdr, 0, count * sizeof *hdr); memset(reference, 0, count * sizeof *reference); fast_psf_accumulator_deposit(&acc, 12.5, 9.5, white, 1.0); fast_psf_accumulator_deposit(&fresh, 12.5, 9.5, white, 1.0); if (fast_psf_accumulator_resolve(&acc, hdr, 2) || fast_psf_accumulator_resolve(&fresh, reference, 2)) fail("cross-frame frame 3 resolve"); for (size_t i = 0; i < count; ++i) if (hdr[i] != reference[i]) { fail("cross-frame frame 3 retained the frame 1 impulse"); break; } free(hdr); free(reference); fast_psf_accumulator_destroy(&fresh); fast_psf_accumulator_destroy(&acc); } static int create_tiny_accumulator(FastPsfAccumulator *acc, int width, int height) { PointSpreadFunction psf = default_psf(); psf.fwhm_pixels = 0.8; return fast_psf_accumulator_init(acc, width, height, 1, FAST_PSF_DEPOSIT_NEAREST, &psf, 1e-6, 0.0, 1); } /* Rewrites the sidecar without the line beginning `key=`. */ static int meta_drop_line(const char *path, const char *key) { FILE *file = fopen(path, "r"); if (file == NULL) return -1; char buffer[2048]; const size_t length = fread(buffer, 1, sizeof buffer - 1, file); fclose(file); buffer[length] = '\0'; const size_t key_length = strlen(key); char out[2048]; size_t written = 0; char *line = buffer; while (line != NULL && *line != '\0' && written + 1 < sizeof out) { char *newline = strchr(line, '\n'); const size_t line_length = newline != NULL ? (size_t)(newline - line) : strlen(line); if (!(line_length >= key_length + 1 && strncmp(line, key, key_length) == 0 && line[key_length] == '=')) { memcpy(out + written, line, line_length); written += line_length; if (newline != NULL) out[written++] = '\n'; } line = newline != NULL ? newline + 1 : NULL; } out[written] = '\0'; file = fopen(path, "w"); if (file == NULL) return -1; const int ok = fputs(out, file) != EOF && fclose(file) == 0; return ok ? 0 : -1; } /* Replaces the first character after `key=` with `replacement`. */ static int meta_corrupt_value(const char *path, const char *key, char replacement) { FILE *file = fopen(path, "r"); if (file == NULL) return -1; char buffer[2048]; const size_t length = fread(buffer, 1, sizeof buffer - 1, file); fclose(file); buffer[length] = '\0'; char *position = strstr(buffer, key); if (position == NULL || position[strlen(key)] != '=') return -1; char *value = &position[strlen(key) + 1]; if (*value == '\0' || *value == '\n') return -1; *value = replacement; file = fopen(path, "w"); if (file == NULL) return -1; const int ok = fputs(buffer, file) != EOF && fclose(file) == 0; return ok ? 0 : -1; } /* Tiny estimate/measure/wisdom-update/wisdom round trip; no large planning. */ static void test_wisdom_modes(void) { const char *path = "/tmp/gr_fast_fftw_wisdom_test"; char meta[256]; snprintf(meta, sizeof meta, "%s.meta", path); unlink(path); unlink(meta); FastPsfAccumulator acc = {0}; if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_ESTIMATE, NULL) || create_tiny_accumulator(&acc, 16, 12)) { fail("plan mode estimate"); } fast_psf_accumulator_destroy(&acc); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_MEASURE, NULL) || create_tiny_accumulator(&acc, 16, 12)) { fail("plan mode measure"); } fast_psf_accumulator_destroy(&acc); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) || create_tiny_accumulator(&acc, 16, 12)) { fail("plan mode wisdom-update"); } fast_psf_accumulator_destroy(&acc); if (access(path, F_OK) != 0 || access(meta, F_OK) != 0) fail("wisdom-update did not write wisdom and meta"); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) || create_tiny_accumulator(&acc, 16, 12)) { fail("plan mode wisdom import"); } fast_psf_accumulator_destroy(&acc); /* A different size must be rejected rather than silently replanned. */ if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 && create_tiny_accumulator(&acc, 22, 12) == 0) fail("mismatched wisdom was not rejected"); fast_psf_accumulator_destroy(&acc); /* A wisdom file without its sidecar is a miss, not a silent replan. */ unlink(meta); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 && create_tiny_accumulator(&acc, 16, 12) == 0) fail("wisdom without meta was accepted"); fast_psf_accumulator_destroy(&acc); /* Regenerate, then corrupt a numeric field: must be rejected. */ if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) || create_tiny_accumulator(&acc, 16, 12)) fail("wisdom-update regeneration"); fast_psf_accumulator_destroy(&acc); if (meta_corrupt_value(meta, "fft_width", 'x') != 0) fail("meta corruption setup"); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 && create_tiny_accumulator(&acc, 16, 12) == 0) fail("malformed wisdom field was accepted"); fast_psf_accumulator_destroy(&acc); /* Regenerate, then drop a required field: must be rejected. */ if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) || create_tiny_accumulator(&acc, 16, 12)) fail("wisdom-update second regeneration"); fast_psf_accumulator_destroy(&acc); if (meta_drop_line(meta, "workers") != 0) fail("meta drop setup"); if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 && create_tiny_accumulator(&acc, 16, 12) == 0) fail("wisdom missing a required field was accepted"); fast_psf_accumulator_destroy(&acc); /* wisdom-update into an unwritable directory must fail initialization. */ if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, "/nonexistent-dir-xyz/wis") == 0 && create_tiny_accumulator(&acc, 16, 12) == 0) fail("wisdom-update into an unwritable directory succeeded"); fast_psf_accumulator_destroy(&acc); fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_ESTIMATE, NULL); unlink(path); unlink(meta); } int main(void) { test_next_smooth_size(); test_no_wraparound_and_empty(); test_channel_isolation(); test_cross_frame_no_residue(FAST_PSF_DEPOSIT_NEAREST); test_cross_frame_no_residue(FAST_PSF_DEPOSIT_BILINEAR); test_wisdom_modes(); const int sizes[][2] = {{17, 13}, {16, 16}, {23, 31}, {33, 17}}; const int supersamples[] = {1, 2, 3, 4}; const FastPsfDeposit deposits[] = {FAST_PSF_DEPOSIT_NEAREST, FAST_PSF_DEPOSIT_BILINEAR}; for (size_t s = 0; s < sizeof sizes / sizeof sizes[0]; ++s) { for (size_t n = 0; n < sizeof supersamples / sizeof supersamples[0]; ++n) { for (size_t d = 0; d < sizeof deposits / sizeof deposits[0]; ++d) { for (Scene scene = SCENE_CENTER_WHITE; scene <= SCENE_EMPTY; ++scene) { char name[128]; snprintf(name, sizeof name, "%dx%d N%d %s scene%d", sizes[s][0], sizes[s][1], supersamples[n], deposits[d] == FAST_PSF_DEPOSIT_NEAREST ? "near" : "bilin", (int)scene); if (run_compare(name, sizes[s][0], sizes[s][1], supersamples[n], deposits[d], scene, 0) != 0) { /* Keep going: collect all failures before summarizing. */ } } } } } run_compare("prefilled background", 24, 20, 2, FAST_PSF_DEPOSIT_NEAREST, SCENE_MULTIPLE, 1); /* One axis (height) stays at the exact FFT size while the other grows. */ run_compare("single-axis padding", 32, 8, 2, FAST_PSF_DEPOSIT_NEAREST, SCENE_CENTER_WHITE, 0); if (g_failures != 0) { fprintf(stderr, "%d fast-PSF FFTW regression failure(s)\n", g_failures); return 1; } puts("fast PSF FFTW regressions passed"); return 0; }