- 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.
650 lines
31 KiB
C
650 lines
31 KiB
C
#include "frame.h"
|
|
#include "lens_map.h"
|
|
#include "optics.h"
|
|
|
|
#include <math.h>
|
|
#include <omp.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
static int mesh_has_hanging_vertex(const FrameLensMesh *mesh) {
|
|
for (size_t triangle = 0; triangle < mesh->triangle_count; ++triangle)
|
|
for (size_t side = 0; side < 3; ++side) {
|
|
const LensVertex *a = &mesh->vertices[mesh->triangles[triangle].vertex[side]];
|
|
const LensVertex *b =
|
|
&mesh->vertices[mesh->triangles[triangle].vertex[(side + 1) % 3]];
|
|
const double dx = b->image_x - a->image_x;
|
|
const double dy = b->image_y - a->image_y;
|
|
const double length_squared = dx * dx + dy * dy;
|
|
for (size_t vertex = 0; vertex < mesh->vertex_count; ++vertex) {
|
|
if (vertex == mesh->triangles[triangle].vertex[side] ||
|
|
vertex == mesh->triangles[triangle].vertex[(side + 1) % 3])
|
|
continue;
|
|
const LensVertex *p = &mesh->vertices[vertex];
|
|
const double px = p->image_x - a->image_x;
|
|
const double py = p->image_y - a->image_y;
|
|
const double cross = px * dy - py * dx;
|
|
const double position = (px * dx + py * dy) / length_squared;
|
|
if (fabs(cross) <= 1e-12 * length_squared && position > 1e-12 &&
|
|
position < 1.0 - 1e-12)
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static int mesh_has_same_winding_shared_edge(const FrameLensMesh *mesh) {
|
|
for (size_t left_triangle = 0; left_triangle < mesh->triangle_count;
|
|
++left_triangle)
|
|
for (size_t left_side = 0; left_side < 3; ++left_side) {
|
|
const size_t from = mesh->triangles[left_triangle].vertex[left_side];
|
|
const size_t to =
|
|
mesh->triangles[left_triangle].vertex[(left_side + 1) % 3];
|
|
for (size_t right_triangle = left_triangle + 1;
|
|
right_triangle < mesh->triangle_count; ++right_triangle)
|
|
for (size_t right_side = 0; right_side < 3; ++right_side) {
|
|
const size_t other_from =
|
|
mesh->triangles[right_triangle].vertex[right_side];
|
|
const size_t other_to =
|
|
mesh->triangles[right_triangle].vertex[(right_side + 1) % 3];
|
|
if ((from == other_from && to == other_to) ||
|
|
(from == other_to && to == other_from)) {
|
|
if (from == other_from && to == other_to)
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int main(void) {
|
|
const int width = 100, height = 100;
|
|
const double test_exposure = 1e-3;
|
|
const double psf_relative_tail = 1e-8;
|
|
const PointSpreadFunction psf = {.fwhm_pixels = 2.7, .moffat_beta = 4.5};
|
|
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.25,
|
|
.max_steps = 100};
|
|
const ObserverState observer = observer_fixed_at_origin();
|
|
Star star = {
|
|
.direction = {0.0, 0.0, -1.0}, .temperature_K = 7000.0, .amplitude = 1.0};
|
|
StarCatalog catalog = {.stars = &star, .count = 1};
|
|
SpacetimeSource spacetime = {0};
|
|
FrameLensMesh mesh = {0};
|
|
double *hdr = calloc((size_t)width * height * 3, sizeof *hdr);
|
|
int result = 1;
|
|
if (blackbody_backend_init(NULL, 0, NAN, NAN, NULL, stderr) ||
|
|
hdr == NULL || spacetime_create_minkowski(&spacetime, 10.0) ||
|
|
frame_lens_mesh_build_coarse(&mesh, width, height, 20, 30.0) ||
|
|
frame_lens_mesh_trace(&mesh, &spacetime, &observer, &trace))
|
|
goto done;
|
|
const size_t images =
|
|
frame_splat_catalog(&mesh, &catalog, hdr, width, height, test_exposure,
|
|
&psf, NULL, INFINITY, 1.0, psf_relative_tail,
|
|
0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
|
|
if (images != 1 || hdr[3 * (50 * width + 50)] <= 0.0) {
|
|
fputs("flat-space inverse lens-map regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
/* A finalized mesh can be persisted independently of spacetime and then
|
|
* drive the exact same catalog inverse-map and PSF pass. */
|
|
const char *lens_map_path = "/tmp/gr_lens_map_test.grlens";
|
|
const LensMapFrame saved_frame = {.frame_id = 7,
|
|
.coordinate_time = 3.0,
|
|
.proper_time = 2.0,
|
|
.mesh = mesh};
|
|
LensMap loaded_map = {0};
|
|
double *roundtrip_hdr = calloc((size_t)width * height * 3, sizeof *roundtrip_hdr);
|
|
if (roundtrip_hdr == NULL ||
|
|
lens_map_write(lens_map_path, width, height, 30.0, &saved_frame, 1) ||
|
|
lens_map_read(lens_map_path, &loaded_map) || loaded_map.frame_count != 1 ||
|
|
loaded_map.frames[0].frame_id != 7 || loaded_map.width != width ||
|
|
loaded_map.height != height ||
|
|
loaded_map.frames[0].mesh.vertex_count != mesh.vertex_count ||
|
|
frame_splat_catalog(&loaded_map.frames[0].mesh, &catalog, roundtrip_hdr,
|
|
width, height, test_exposure, &psf, NULL, INFINITY,
|
|
1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL) != images) {
|
|
fputs("lens-map round-trip regression failed\n", stderr);
|
|
free(roundtrip_hdr); lens_map_destroy(&loaded_map); unlink(lens_map_path);
|
|
goto done;
|
|
}
|
|
for (int value = 0; value < width * height * 3; ++value)
|
|
if (hdr[value] != roundtrip_hdr[value]) {
|
|
fputs("lens-map round-trip HDR regression failed\n", stderr);
|
|
free(roundtrip_hdr); lens_map_destroy(&loaded_map); unlink(lens_map_path);
|
|
goto done;
|
|
}
|
|
free(roundtrip_hdr);
|
|
lens_map_destroy(&loaded_map);
|
|
/* A damaged payload must not be mistaken for a reusable physical map. */
|
|
FILE *damaged = fopen(lens_map_path, "r+b");
|
|
int damage_failed = damaged == NULL;
|
|
if (!damage_failed) {
|
|
if (fseek(damaged, -5L, SEEK_END))
|
|
damage_failed = 1;
|
|
const int original = damage_failed ? EOF : fgetc(damaged);
|
|
if (damage_failed || fseek(damaged, -5L, SEEK_END) || original == EOF ||
|
|
fputc(original ^ 0xff, damaged) == EOF)
|
|
damage_failed = 1;
|
|
}
|
|
if (damaged != NULL && fclose(damaged))
|
|
damage_failed = 1;
|
|
if (damage_failed || !lens_map_read(lens_map_path, &loaded_map)) {
|
|
fputs("lens-map corruption rejection regression failed\n", stderr);
|
|
lens_map_destroy(&loaded_map); unlink(lens_map_path);
|
|
goto done;
|
|
}
|
|
unlink(lens_map_path);
|
|
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
|
PsfSplatStats min_y_stats = {0};
|
|
if (frame_splat_catalog(&mesh, &catalog, hdr, width, height, test_exposure,
|
|
&psf, NULL, INFINITY, 1.0, psf_relative_tail,
|
|
1e300, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, &min_y_stats, NULL, NULL, NULL) != 1 ||
|
|
min_y_stats.discarded_below_min_y != 1 ||
|
|
hdr[3 * (50 * width + 50)] != 0.0) {
|
|
fputs("PSF minimum-Y discard regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
/* Private HDR accumulation must preserve the serial splat result. */
|
|
double *serial_hdr = calloc((size_t)width * height * 3, sizeof *serial_hdr);
|
|
double *parallel_hdr = calloc((size_t)width * height * 3, sizeof *parallel_hdr);
|
|
if (serial_hdr == NULL || parallel_hdr == NULL) {
|
|
free(serial_hdr);
|
|
free(parallel_hdr);
|
|
goto done;
|
|
}
|
|
const int original_threads = omp_get_max_threads();
|
|
omp_set_dynamic(0);
|
|
omp_set_num_threads(1);
|
|
const size_t serial_images = frame_splat_catalog(
|
|
&mesh, &catalog, serial_hdr, width, height, test_exposure, &psf, NULL,
|
|
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
|
|
omp_set_num_threads(4);
|
|
const size_t parallel_images = frame_splat_catalog(
|
|
&mesh, &catalog, parallel_hdr, width, height, test_exposure, &psf, NULL,
|
|
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
|
|
omp_set_num_threads(original_threads);
|
|
for (int value = 0; value < width * height * 3; ++value)
|
|
if (fabs(serial_hdr[value] - parallel_hdr[value]) >
|
|
1e-12 * fmax(1.0, fabs(serial_hdr[value]))) {
|
|
fputs("parallel catalog splat regression failed\n", stderr);
|
|
free(serial_hdr);
|
|
free(parallel_hdr);
|
|
goto done;
|
|
}
|
|
free(serial_hdr);
|
|
free(parallel_hdr);
|
|
if (serial_images != 1 || parallel_images != serial_images) {
|
|
fputs("parallel catalog image-count regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
const LinearRgb cool = blackbody_to_linear_rgb(3000.0);
|
|
const LinearRgb hot = blackbody_to_linear_rgb(10000.0);
|
|
if (!(cool.r > cool.b && hot.b > hot.r &&
|
|
hot.r + hot.g + hot.b > cool.r + cool.g + cool.b)) {
|
|
fputs("blackbody spectral-color regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
/* The Moffat is flux-normalized and retains a measurable, continuous wing
|
|
* beyond the former Gaussian's 3-sigma raster box. */
|
|
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
|
splat_moffat(hdr, width, height, 50.5, 50.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf, psf_relative_tail, 0.0);
|
|
double moffat_flux = 0.0;
|
|
for (int pixel = 0; pixel < width * height; ++pixel)
|
|
moffat_flux += hdr[3 * pixel];
|
|
if (fabs(moffat_flux - 1.0) > 0.01 ||
|
|
hdr[3 * (50 * width + 62)] <= 0.0) {
|
|
fputs("Moffat normalization or wing regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
/* The cache stores 4-point pixel-area integrals over a 64x64 sub-pixel
|
|
* lattice. Compare its bilinear interpolation with the independent 8-point
|
|
* direct reference at phases on both sides of a pixel boundary. */
|
|
PsfKernelCache cache = {0};
|
|
double *cached_hdr = calloc((size_t)width * height * 3, sizeof *cached_hdr);
|
|
double *reference_hdr = calloc((size_t)width * height * 3, sizeof *reference_hdr);
|
|
if (cached_hdr == NULL || reference_hdr == NULL ||
|
|
psf_kernel_cache_init(&cache, &psf, psf_relative_tail)) {
|
|
free(cached_hdr);
|
|
free(reference_hdr);
|
|
psf_kernel_cache_destroy(&cache);
|
|
fputs("PSF cache construction regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
PsfKernelCache loose_tail_cache = {0};
|
|
if (psf_kernel_cache_init(&loose_tail_cache, &psf, 1e-5) ||
|
|
loose_tail_cache.relative_tail_fraction != 1e-5 ||
|
|
loose_tail_cache.radius_pixels >= cache.radius_pixels) {
|
|
fputs("PSF relative-tail cache-radius regression failed\n", stderr);
|
|
psf_kernel_cache_destroy(&loose_tail_cache);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
psf_kernel_cache_destroy(&loose_tail_cache);
|
|
/* This is the exact eligibility split that a future event sink exposes to
|
|
* HIP: cache event, CPU direct fallback, or min-Y discard. */
|
|
PsfCachedEvent prepared = {0};
|
|
if (psf_prepare_cached_event(&prepared, 12.25, 14.75,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf, &cache,
|
|
1.0, psf_relative_tail, 0.0) != 0 ||
|
|
prepared.x != 12.25 || prepared.y != 14.75 ||
|
|
!(prepared.support_radius > 0.0) ||
|
|
psf_prepare_cached_event(&prepared, 12.25, 14.75,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1000.0, &psf, &cache,
|
|
1.0, psf_relative_tail, 0.0) != 1 ||
|
|
psf_prepare_cached_event(&prepared, 12.25, 14.75,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf, &cache,
|
|
1.0, psf_relative_tail, 1.0) != 3) {
|
|
fputs("PSF event eligibility regression failed\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
const double phases[][2] = {{0.01, 0.99}, {0.499, 0.501}, {0.999, 0.001}};
|
|
for (size_t phase = 0; phase < sizeof phases / sizeof *phases; ++phase) {
|
|
memset(cached_hdr, 0, (size_t)width * height * 3 * sizeof *cached_hdr);
|
|
memset(reference_hdr, 0, (size_t)width * height * 3 * sizeof *reference_hdr);
|
|
if (splat_moffat_cached(cached_hdr, width, height, 50.0 + phases[phase][0],
|
|
50.0 + phases[phase][1], (LinearRgb){1.0, 1.0, 1.0},
|
|
1.0, &psf, &cache, 1.0, psf_relative_tail, 0.0) != 0) {
|
|
fputs("ordinary PSF cache unexpectedly fell back\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
splat_moffat_direct(reference_hdr, width, height,
|
|
50.0 + phases[phase][0], 50.0 + phases[phase][1],
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf, psf_relative_tail, 0.0);
|
|
double peak = 0.0, max_error = 0.0;
|
|
for (int value = 0; value < width * height * 3; ++value) {
|
|
peak = fmax(peak, reference_hdr[value]);
|
|
max_error = fmax(max_error, fabs(cached_hdr[value] - reference_hdr[value]));
|
|
}
|
|
if (peak <= 0.0 || max_error > 4e-5 * peak) {
|
|
fputs("PSF cache interpolation accuracy regression failed\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
}
|
|
if (splat_moffat_cached(cached_hdr, width, height, 50.5, 50.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf, &cache,
|
|
1.0, psf_relative_tail, 1.0) != 3) {
|
|
fputs("PSF minimum-Y cached discard regression failed\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
/* A bright event must avoid a cached hard cutoff by selecting the direct
|
|
* reference path when the requested support exceeds the cache. */
|
|
if (splat_moffat_cached(cached_hdr, width, height, 50.5, 50.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1000.0, &psf, &cache, 1.0,
|
|
psf_relative_tail, 0.0) != 1) {
|
|
fputs("bright PSF direct-fallback regression failed\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
memset(cached_hdr, 0, (size_t)width * height * 3 * sizeof *cached_hdr);
|
|
memset(reference_hdr, 0, (size_t)width * height * 3 * sizeof *reference_hdr);
|
|
if (splat_moffat_cached(cached_hdr, width, height, 50.5, 50.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1000.0, &psf, &cache,
|
|
1000.0, psf_relative_tail, 0.0) != 2) {
|
|
fputs("bright PSF cached-wing-clipping regression failed\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
splat_moffat_direct(reference_hdr, width, height, 50.5, 50.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1000.0, &psf, psf_relative_tail, 0.0);
|
|
const size_t center = 3 * (50 * width + 50);
|
|
if (cached_hdr[center] <= 0.0 ||
|
|
fabs(cached_hdr[center] - reference_hdr[center]) >
|
|
4e-5 * reference_hdr[center]) {
|
|
fputs("cached-wing clipping changed the bright PSF core\n", stderr);
|
|
free(cached_hdr); free(reference_hdr); psf_kernel_cache_destroy(&cache);
|
|
goto done;
|
|
}
|
|
free(cached_hdr);
|
|
free(reference_hdr);
|
|
psf_kernel_cache_destroy(&cache);
|
|
/* Fast mode: a nearest deposit must reproduce the current pixel-integrated
|
|
* Moffat at the snapped supersampled centre, preserve total flux, and honour
|
|
* the min-Y discard rule; bilinear deposition must preserve the centroid. */
|
|
{
|
|
int fast_ok = 1;
|
|
const int supersample = 2;
|
|
FastPsfAccumulator fast = {0};
|
|
FastPsfAccumulator bilinear = {0};
|
|
FastPsfAccumulator min_y_fast = {0};
|
|
FastPsfAccumulator accumulation = {0};
|
|
double *fast_hdr = calloc((size_t)width * height * 3, sizeof *fast_hdr);
|
|
double *direct_hdr = calloc((size_t)width * height * 3, sizeof *direct_hdr);
|
|
double *background_hdr =
|
|
calloc((size_t)width * height * 3, sizeof *background_hdr);
|
|
if (fast_hdr == NULL || direct_hdr == NULL || background_hdr == NULL ||
|
|
fast_psf_accumulator_init(&fast, width, height, supersample,
|
|
FAST_PSF_DEPOSIT_NEAREST, &psf,
|
|
psf_relative_tail, 0.0, 1) ||
|
|
fast_psf_accumulator_init(&bilinear, width, height, supersample,
|
|
FAST_PSF_DEPOSIT_BILINEAR, &psf,
|
|
psf_relative_tail, 0.0, 1) ||
|
|
fast_psf_accumulator_init(&min_y_fast, width, height, supersample,
|
|
FAST_PSF_DEPOSIT_NEAREST, &psf,
|
|
psf_relative_tail, 0.5, 1) ||
|
|
fast_psf_accumulator_init(&accumulation, width, height, supersample,
|
|
FAST_PSF_DEPOSIT_NEAREST, &psf,
|
|
psf_relative_tail, 0.0, 1)) {
|
|
fputs("fast-mode accumulator construction regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* (50.2, 50.2) snaps to supersampled cell 100, centre (100.5, 100.5) in
|
|
* ss coordinates, i.e. final position (50.25, 50.25). */
|
|
if (fast_psf_accumulator_deposit(&fast, 50.2, 50.2,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0) == 3 ||
|
|
fast_psf_accumulator_resolve(&fast, fast_hdr, 4)) {
|
|
fputs("fast-mode nearest deposit regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
splat_moffat_direct(direct_hdr, width, height, 50.25, 50.25,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf,
|
|
psf_relative_tail, 0.0);
|
|
double peak = 0.0, max_error = 0.0, fast_flux = 0.0, direct_flux = 0.0;
|
|
for (int value = 0; value < width * height * 3; ++value) {
|
|
peak = fmax(peak, direct_hdr[value]);
|
|
max_error = fmax(max_error, fabs(fast_hdr[value] - direct_hdr[value]));
|
|
fast_flux += fast_hdr[value];
|
|
direct_flux += direct_hdr[value];
|
|
}
|
|
if (!(peak > 0.0) || max_error > 1e-4 * peak ||
|
|
fabs(fast_flux - direct_flux) > 1e-4) {
|
|
fputs("fast-mode nearest semantics regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* Bilinear keeps the exact continuous centroid. */
|
|
memset(fast_hdr, 0, (size_t)width * height * 3 * sizeof *fast_hdr);
|
|
if (fast_psf_accumulator_deposit(&bilinear, 50.37, 50.62,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0) == 3 ||
|
|
fast_psf_accumulator_resolve(&bilinear, fast_hdr, 4)) {
|
|
fputs("fast-mode bilinear deposit regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
double weight_sum = 0.0, cx = 0.0, cy = 0.0;
|
|
for (int row = 0; row < height; ++row)
|
|
for (int column = 0; column < width; ++column) {
|
|
const double weight = fast_hdr[3 * (row * width + column)];
|
|
weight_sum += weight;
|
|
cx += weight * (column + 0.5);
|
|
cy += weight * (row + 0.5);
|
|
}
|
|
if (!(weight_sum > 0.0) ||
|
|
hypot(cx / weight_sum - 50.37, cy / weight_sum - 50.62) > 1e-6) {
|
|
fputs("fast-mode bilinear centroid regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* The min-Y cutoff discards an event whose peak luminance is below it. */
|
|
if (fast_psf_accumulator_deposit(&fast, 10.5, 10.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0) != 0 ||
|
|
fast_psf_accumulator_deposit(&min_y_fast, 10.5, 10.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0) != 3) {
|
|
fputs("fast-mode min-Y discard regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* End-to-end plumbing through the frame splat path. */
|
|
memset(fast_hdr, 0, (size_t)width * height * 3 * sizeof *fast_hdr);
|
|
PsfSplatStats fast_stats = {0};
|
|
const size_t fast_images = frame_splat_catalog(
|
|
&mesh, &catalog, fast_hdr, width, height, test_exposure, &psf, NULL,
|
|
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1,
|
|
FRAME_CATALOG_PREFETCH_FRAME, NULL, &fast_stats, NULL, &fast, NULL);
|
|
if (fast_images != 1 || fast_stats.discarded_below_min_y != 0) {
|
|
fputs("fast-mode frame splat regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* HDR accumulation semantics: resolve must add onto an existing
|
|
* background, not overwrite it. A prefilled buffer plus one deposit must
|
|
* preserve the far-field background exactly and add the PSF core. */
|
|
for (int value = 0; value < width * height * 3; ++value)
|
|
background_hdr[value] = 0.25;
|
|
if (fast_psf_accumulator_deposit(&accumulation, 10.5, 10.5,
|
|
(LinearRgb){1.0, 1.0, 1.0}, 1.0) == 3 ||
|
|
fast_psf_accumulator_resolve(&accumulation, background_hdr, 4)) {
|
|
fputs("fast-mode HDR accumulation regression failed\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
/* (90, 90) is far outside the kernel support of a star at (10.5, 10.5). */
|
|
if (background_hdr[3 * (90 * width + 90)] != 0.25) {
|
|
fputs("fast-mode HDR accumulation lost the background\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
if (!(background_hdr[3 * (10 * width + 10)] > 0.25)) {
|
|
fputs("fast-mode HDR accumulation did not add the deposit\n", stderr);
|
|
fast_ok = 0;
|
|
goto fast_done;
|
|
}
|
|
fast_done:
|
|
free(fast_hdr);
|
|
free(direct_hdr);
|
|
free(background_hdr);
|
|
fast_psf_accumulator_destroy(&fast);
|
|
fast_psf_accumulator_destroy(&bilinear);
|
|
fast_psf_accumulator_destroy(&min_y_fast);
|
|
fast_psf_accumulator_destroy(&accumulation);
|
|
if (!fast_ok)
|
|
goto done;
|
|
}
|
|
frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5);
|
|
if (hdr[3 * (10 * width + 20)] != 0.25) {
|
|
fputs("mesh diagnostic overlay regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
/* A fixed absolute edge tolerance used to make tiny source triangles claim
|
|
* sources far outside their field. */
|
|
FrameLensMesh fine_mesh = {0};
|
|
Star fine_stars[2] = {{.direction = {0.0, 0.0, -1.0},
|
|
.temperature_K = 7000.0,
|
|
.amplitude = 1.0},
|
|
{.direction = {0.01, 0.0, -0.9999499987499375},
|
|
.temperature_K = 7000.0,
|
|
.amplitude = 1.0}};
|
|
StarCatalog fine_catalog = {.stars = fine_stars, .count = 2};
|
|
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
|
if (frame_lens_mesh_build_coarse(&fine_mesh, width, height, 1, 0.1) ||
|
|
frame_lens_mesh_trace(&fine_mesh, &spacetime, &observer, &trace) ||
|
|
frame_splat_catalog(&fine_mesh, &fine_catalog, hdr, width, height,
|
|
test_exposure, &psf, NULL, INFINITY, 1.0,
|
|
psf_relative_tail, 0.0, 0, 1,
|
|
FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL,
|
|
NULL) != 1) {
|
|
fputs("fine source-triangle containment regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&fine_mesh);
|
|
goto done;
|
|
}
|
|
frame_lens_mesh_destroy(&fine_mesh);
|
|
/* Schwarzschild level-4/J=0.2 ring triangle: the old edge tolerance accepts
|
|
* (1,0,0) although it is outside, producing unsigned weights summing to
|
|
* 1.09608. Keep a genuine interior source after it to check that rejecting
|
|
* one source does not discard subsequent stars. Exercise both parities. */
|
|
const double thin_directions[3][3] = {
|
|
{0.99999998891116071, 0.00013431364716360775, 6.4323579270168807e-05},
|
|
{0.99999997228355786, -0.00021216644782556991, -0.00010206998544149922},
|
|
{0.9999927016945267, -0.0034455730513416835, -0.001650631403158936}};
|
|
LensVertex thin_vertices[3] = {
|
|
{.image_x = 40, .image_y = 40, .status = RAY_ENDPOINT_ESCAPED},
|
|
{.image_x = 48, .image_y = 40, .status = RAY_ENDPOINT_ESCAPED},
|
|
{.image_x = 40, .image_y = 48, .status = RAY_ENDPOINT_ESCAPED}};
|
|
LensTriangle thin_triangle = {.vertex = {0, 1, 2}};
|
|
FrameLensMesh thin_mesh = {.vertices = thin_vertices, .vertex_count = 3,
|
|
.triangles = &thin_triangle, .triangle_count = 1};
|
|
Star thin_stars[2] = {
|
|
{.direction = {1, 0, 0}, .temperature_K = 7000, .amplitude = 1},
|
|
{.temperature_K = 7000, .amplitude = 1}};
|
|
for (int i = 0; i < 3; ++i) {
|
|
memcpy(thin_vertices[i].n_infinity, thin_directions[i],
|
|
sizeof thin_directions[i]);
|
|
memcpy(thin_vertices[i].camera_direction, thin_directions[i],
|
|
sizeof thin_directions[i]);
|
|
for (int axis = 0; axis < 3; ++axis)
|
|
thin_stars[1].direction[axis] += thin_directions[i][axis];
|
|
}
|
|
const double thin_norm = hypot(hypot(thin_stars[1].direction[0],
|
|
thin_stars[1].direction[1]),
|
|
thin_stars[1].direction[2]);
|
|
for (int axis = 0; axis < 3; ++axis)
|
|
thin_stars[1].direction[axis] /= thin_norm;
|
|
StarCatalog thin_catalog = {.stars = thin_stars, .count = 2};
|
|
for (int parity = 0; parity < 2; ++parity) {
|
|
thin_triangle.vertex[1] = parity ? 2 : 1;
|
|
thin_triangle.vertex[2] = parity ? 1 : 2;
|
|
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
|
if (frame_splat_catalog(&thin_mesh, &thin_catalog, hdr, width, height,
|
|
test_exposure, &psf, NULL, 1.0, 1.0,
|
|
psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL) != 1 ||
|
|
hdr[3 * (43 * width + 43)] <= 0.0) {
|
|
fputs("thin source-triangle inverse-map regression failed\n", stderr);
|
|
goto done;
|
|
}
|
|
}
|
|
/* Refinement probes are temporary until their generation is complete. A
|
|
* shared diagonal probe must produce one stable midpoint and conforming
|
|
* children only after its endpoint has been installed. */
|
|
FrameLensMesh adaptive_mesh = {0};
|
|
RefinementConfig refine = {.max_level = 1,
|
|
.angle_absolute_rad = 1e-4,
|
|
.angle_relative = 1e-4,
|
|
.jacobian_minimum = 1e-3,
|
|
.min_edge_pixels = 1.0,
|
|
.min_area_pixels2 = 1.0};
|
|
if (frame_lens_mesh_build_coarse(&adaptive_mesh, width, height, 100, 30.0))
|
|
goto done;
|
|
for (size_t i = 0; i < adaptive_mesh.vertex_count; ++i) {
|
|
adaptive_mesh.vertices[i].traced = 1;
|
|
adaptive_mesh.vertices[i].status = RAY_ENDPOINT_ESCAPED;
|
|
adaptive_mesh.vertices[i].n_infinity[0] = 1.0;
|
|
}
|
|
if (frame_lens_mesh_prepare_generation(&adaptive_mesh, &refine) != 1) {
|
|
fputs("adaptive shared-edge probe setup regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
const RayEndpoint bent_probe = {.n_infinity = {0.0, 1.0, 0.0},
|
|
.frequency_ratio = 1.0,
|
|
.status = RAY_ENDPOINT_ESCAPED};
|
|
if (frame_lens_mesh_install_sample(&adaptive_mesh, 0, &bent_probe) ||
|
|
frame_lens_mesh_finish_generation(&adaptive_mesh, &refine) != 1 ||
|
|
adaptive_mesh.vertex_count != 5 || adaptive_mesh.triangle_count != 4) {
|
|
fputs("adaptive shared-edge split regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
/* A capture/escape discontinuity is a shadow boundary, not a smooth map
|
|
* error: request all three midpoint rays and red-refine in one generation. */
|
|
if (frame_lens_mesh_build_coarse(&adaptive_mesh, width, height, 100, 30.0))
|
|
goto done;
|
|
adaptive_mesh.triangle_count = 1;
|
|
for (size_t i = 0; i < adaptive_mesh.vertex_count; ++i) {
|
|
adaptive_mesh.vertices[i].traced = 1;
|
|
adaptive_mesh.vertices[i].status = RAY_ENDPOINT_ESCAPED;
|
|
adaptive_mesh.vertices[i].n_infinity[0] = 1.0;
|
|
}
|
|
adaptive_mesh.vertices[0].status = RAY_ENDPOINT_CAPTURED;
|
|
refine.max_level = 1;
|
|
refine.angle_absolute_rad = 3.14159265358979323846;
|
|
refine.angle_relative = 1e6;
|
|
refine.jacobian_minimum = 1e-12;
|
|
if (frame_lens_mesh_prepare_generation(&adaptive_mesh, &refine) != 3) {
|
|
fputs("shadow-boundary red-probe setup regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
for (size_t i = 0; i < adaptive_mesh.sample_count; ++i)
|
|
if (frame_lens_mesh_install_sample(&adaptive_mesh, i, &bent_probe)) {
|
|
fputs("shadow-boundary red-probe installation regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
if (frame_lens_mesh_finish_generation(&adaptive_mesh, &refine) != 3 ||
|
|
adaptive_mesh.vertex_count != 7 || adaptive_mesh.triangle_count != 4) {
|
|
fputs("shadow-boundary red-refinement regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
/* Two shadow leaves can force two edges of an escaped neighbour. That
|
|
* neighbour must use a local three-child blue split, not create a third
|
|
* requested edge that spreads red refinement farther outward. */
|
|
if (frame_lens_mesh_build_coarse(&adaptive_mesh, 200, 100, 100, 30.0))
|
|
goto done;
|
|
adaptive_mesh.triangle_count = 3;
|
|
for (size_t i = 0; i < adaptive_mesh.vertex_count; ++i) {
|
|
adaptive_mesh.vertices[i].traced = 1;
|
|
adaptive_mesh.vertices[i].status = RAY_ENDPOINT_ESCAPED;
|
|
adaptive_mesh.vertices[i].n_infinity[0] = 1.0;
|
|
}
|
|
adaptive_mesh.vertices[3].status = RAY_ENDPOINT_CAPTURED;
|
|
adaptive_mesh.vertices[5].status = RAY_ENDPOINT_CAPTURED;
|
|
if (frame_lens_mesh_prepare_generation(&adaptive_mesh, &refine) != 6) {
|
|
fputs("shadow-boundary blue-neighbour probe setup regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
for (size_t i = 0; i < adaptive_mesh.sample_count; ++i)
|
|
if (frame_lens_mesh_install_sample(&adaptive_mesh, i, &bent_probe)) {
|
|
fputs("shadow-boundary blue-neighbour probe installation regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
if (frame_lens_mesh_finish_generation(&adaptive_mesh, &refine) != 6 ||
|
|
adaptive_mesh.vertex_count != 12 || adaptive_mesh.triangle_count != 11 ||
|
|
mesh_has_hanging_vertex(&adaptive_mesh) ||
|
|
mesh_has_same_winding_shared_edge(&adaptive_mesh)) {
|
|
fputs("shadow-boundary blue-neighbour refinement regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
const RayEndpoint flat_probe = {.n_infinity = {1.0, 0.0, 0.0},
|
|
.frequency_ratio = 1.0,
|
|
.status = RAY_ENDPOINT_ESCAPED};
|
|
/* Opposite nonzero discrete-Jacobian signs on the two sides of the shared
|
|
* diagonal require a sufficiently small magnitude before requesting it. */
|
|
refine.jacobian_minimum = 10.0;
|
|
refine.angle_absolute_rad = 3.14159265358979323846;
|
|
refine.angle_relative = 1e6;
|
|
if (frame_lens_mesh_build_coarse(&adaptive_mesh, width, height, 100, 30.0))
|
|
goto done;
|
|
const double source_directions[4][3] = {
|
|
{1.0, 0.0, 0.0},
|
|
{sqrt(0.99), 0.0, 0.1},
|
|
{sqrt(0.99), 0.0, 0.1},
|
|
{sqrt(0.98), 0.1, 0.1}};
|
|
for (size_t i = 0; i < adaptive_mesh.vertex_count; ++i) {
|
|
adaptive_mesh.vertices[i].traced = 1;
|
|
adaptive_mesh.vertices[i].status = RAY_ENDPOINT_ESCAPED;
|
|
memcpy(adaptive_mesh.vertices[i].n_infinity, source_directions[i],
|
|
sizeof source_directions[i]);
|
|
}
|
|
if (frame_lens_mesh_prepare_generation(&adaptive_mesh, &refine) != 1 ||
|
|
frame_lens_mesh_install_sample(&adaptive_mesh, 0, &flat_probe) ||
|
|
frame_lens_mesh_finish_generation(&adaptive_mesh, &refine) != 1 ||
|
|
adaptive_mesh.vertex_count != 5 || adaptive_mesh.triangle_count != 4) {
|
|
fputs("adaptive fold-parity split regression failed\n", stderr);
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
goto done;
|
|
}
|
|
frame_lens_mesh_destroy(&adaptive_mesh);
|
|
result = 0;
|
|
done:
|
|
frame_lens_mesh_destroy(&mesh);
|
|
spacetime_destroy(&spacetime);
|
|
blackbody_backend_destroy();
|
|
free(hdr);
|
|
return result;
|
|
}
|