Optics: cache pixel-area Moffat kernels

This commit is contained in:
wyj committed 2026-08-28 13:53:06 -04:00
1 parent a71180706f
commit 80b891d2c8
10 files changed
+693 -56

No files matched your search

+8 -1
View File
@@ -22,8 +22,9 @@ FRAME_TEST_TARGET := build/test_frame
SCHWARZSCHILD_TEST_TARGET := build/test_schwarzschild
OBSERVER_TRACK_TEST_TARGET := build/test_observer_track
CATALOG_PREFETCH_TEST_TARGET := build/test_catalog_prefetch
PSF_HDR_TEST_TARGET := build/minkowski_psf_hdr_test
.PHONY: all clean run test minkowski schwarzschild
.PHONY: all clean run test minkowski schwarzschild psf-hdr-test
ifeq ($(SPACETIME),minkowski)
BACKEND_CPPFLAGS := -DSPACETIME_MINKOWSKI
@@ -51,6 +52,12 @@ minkowski:
schwarzschild:
$(MAKE) SPACETIME=schwarzschild all
# Deliberately separate from the production binary: enables --hdr-output PFM.
$(PSF_HDR_TEST_TARGET): $(COMMON_SOURCES) src/spacetime_minkowski.c src/main.c | build
$(CC) $(CPPFLAGS) -DENABLE_HDR_DEBUG -DSPACETIME_MINKOWSKI $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
psf-hdr-test: $(PSF_HDR_TEST_TARGET)
$(TEST_TARGET): tests/test_geodesic.c $(CORE_MINKOWSKI_SOURCES) | build
$(CC) $(CPPFLAGS) $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
+13
View File
@@ -158,6 +158,19 @@ Point sources use a flux-normalized circular Moffat PSF by default
1.15-pixel Gaussian core while the Moffat wings remain continuous; both values
are display/optics calibration parameters.
The default renderer builds one immutable, process-wide 64-by-64 sub-pixel
Moffat lookup kernel. Its weights are pixel-area integrals and are bilinearly
interpolated between phase tables. The renderer reports its build time and
cached/direct-fallback image counts. Pass `--psf-direct` to use the slower
8-point quadrature reference evaluator for regression comparisons; an image
whose required HDR-tail support exceeds the cache radius selects that reference
path automatically.
For PSF validation only, `make psf-hdr-test` builds
`build/minkowski_psf_hdr_test`, a separate binary with a `--hdr-output PATH`
option. It writes the pre-tone-mapping RGB framebuffer as a 32-bit float PFM
image; the ordinary binaries do not contain this option or writer.
Pass `--draw-mesh` to alpha-composite image-plane triangle edges as
one-pixel-wide 0.5 linear-gray diagnostic lines at 0.5 opacity. The line
rasterizer uses coverage-based antialiasing.
@@ -0,0 +1,65 @@
# 2MASS 全天 1080p Moffat lookup 基准(2026-08-28)
## 目的
测量 `PsfKernelCache` 在真实全天 2MASS catalog、高星像数单帧中的端到端
收益,并与查表实现之前的直接 Moffat 路径比较。两个运行使用相同的相机、
catalog、输出尺寸与曝光;唯一意图中的算法差异是 PSF 计算。
## 可复现环境
- Git base revision: `a71180706f3abfb9fe7669a2366944ea3141d78c`。查表结果来自
其上的 PSF lookup 工作树,随后由本次提交固化;基线为改动前二进制。
- Host CPU: 12th Gen Intel Core i7-12700K,16 个 online logical CPUs。
- Build defaults: C11、`-march=native -O2 -pipe`、OpenMP、PNG output。
- Catalog loading: `--catalog-load-workers 4`(默认值)。
- Render workers: 1920 x 1080 的 RGB `double` private HDR buffer 约为
47.5 MiB;在 `FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES = 512 MiB` 限制下,
最多使用 **10 个** private-HDR render workers。
- Catalog: `assets/2mass/processed/all_sky`;本视场请求并加载 9,328 个
tiles,共 18,148,775 颗 catalog stars。
## 控制变量与命令
相机朝向南天极 `(RA, Dec) = (0 deg, -90 deg)`,60-degree 水平视场,
1920 x 1080,`--coarse-cell-pixels 16`,exposure `1e12`。两次均产生
16,555,241 个星像。
```sh
time ./build/minkowski_sky \
--all-sky-catalog assets/2mass/processed/all_sky \
--look-ra-deg 0 --look-dec-deg -90 \
--width 1920 --height 1080 \
--coarse-cell-pixels 16 --fov-deg 60 \
--exposure 1e12 \
--output output/imgs/2mass_1080p_60deg_south_pole_1e12_psf_cache.png
```
用 `--psf-direct` 可在同一查表版本中运行 8-point 直接积分参考;历史基线
在该选项加入前使用逐像素 `pow()` 直接 Moffat 求值。
## 结果
| 路径 | images | cache build | cached / direct fallback | prefetch | user | system | wall |
|---|---:|---:|---:|---:|---:|---:|---:|
| 历史直接 Moffat | 16,555,241 | — | — | 3.913 s | 1617.84 s | 2.33 s | 165.59 s |
| 64x64 lookup | 16,555,241 | 6.520 s | 16,555,241 / 0 | 3.981 s | 645.24 s | 4.02 s | 74.48 s |
查表版本端到端 wall time 加速为 `165.59 / 74.48 = 2.22x`,节省 91.11 s
(55.0%);user CPU time 加速为 `2.51x`。预取差异仅 0.068 s,不能解释
该提升。扣除一次性 kernel build 后,查表运行剩余 67.96 s;该数仅用于说明
多帧或更多星像时的摊销潜力,不应与没有建表步骤的历史单帧总时间混为同一
端到端指标。
这次 lookup 运行的 `cached splats` 等于 image count,且 direct fallback 为
零,因此该速度结果没有把亮星回退的成本隐藏在统计之外。
## 输出一致性与范围
本基准确认 catalog 预取、source-triangle 查询和星像计数在优化前后不变。
PSF 的 float-HDR 像素精度验收另见单星直接积分比较:默认 `FWHM=2.7,
beta=4.5` 的最大误差为直接参考峰值的 `3.18e-5`,RMS 为 `8.25e-7`。
本记录比较的是历史点采样直接路径与新的像素面积积分 lookup 路径的性能。
在需要逐像素数值回归时,使用新二进制的 `--psf-direct`(8-point
pixel-area reference)而非历史路径。
+129
View File
@@ -0,0 +1,129 @@
# CPU PSF lookup-table optimization plan
## Goal
Speed up the current CPU point-source rendering path when the physically
correct lens map produces very many star images. This plan changes only how a
known image's Moffat PSF is evaluated and accumulated. It does **not** change
the catalog representation, inverse lens map, number of images, frequency
shift, magnification, adaptive-mesh work, or use of the GPU.
The target is to replace the per-covered-pixel Moffat `pow()` evaluation with
a lookup of the same continuously translated PSF. It must preserve continuous
sub-pixel image positions and have an explicit, testable bound on PSF-tail
error, including for bright sources.
## Interface and ownership
Add a `PsfKernelCache` owned by the rendering settings for the lifetime of one
program invocation. Exactly one cache is built after command-line parsing,
using the process-wide values of:
- `--psf-fwhm-pixels`;
- `--psf-moffat-beta`;
- a named PSF-tail/display-error configuration;
- a fixed sub-pixel phase resolution.
It is immutable after construction, shared read-only by splat workers, and is
destroyed after rendering. It is not serialized to disk and no multi-parameter
cache or LRU is required: the current program has one PSF parameter pair for
the entire image or movie.
`splat_moffat()` gains a cache-aware path (or is replaced by an equivalently
named cache-aware function). A direct-evaluation implementation remains
available as a reference and as a fallback if cache construction fails.
## Kernel construction
For each sub-pixel phase `(fx, fy)`, where
`fx = x - floor(x)` and `fy = y - floor(y)`, build a scalar kernel containing
the **pixel-area integral** of the normalized circular Moffat profile over each
integer pixel footprint. Kernel weights are independent of color and flux.
Use a fixed, documented numerical quadrature whose convergence is verified
against a higher-accuracy reference.
At runtime, retain the image event's full-precision `(x, y)`. Select the four
neighboring phase tables and bilinearly interpolate their weights. Do not
round to a nearest phase table. Normalize every interior phase kernel so its
weight sum is one; an image clipped by the framebuffer boundary retains the
current behavior of losing the off-frame contribution rather than being
renormalized.
Start with a 64 by 64 phase grid. This is an initial implementation value,
not an accuracy claim: increase it if the phase-continuity and reference-image
tests below do not meet their thresholds.
## Tail support and bright sources
A Moffat profile has infinite support, so a finite renderer needs an explicit
finite-support approximation. Do not rely solely on the current fixed
relative omitted-flux fraction for every source. For each image event, choose
the support radius from its final post-lens, post-frequency-shift, post-exposure
flux so that both of these hold:
- omitted integrated flux is below a configured absolute frame error budget;
- the PSF value at the support boundary is below a configured per-pixel HDR
error budget.
The cache contains weights through the largest radius required by the selected
configuration. A dim image uses a cropped subset; an unusually bright image
uses a larger subset. If an event requires a radius beyond the cache maximum,
use the direct Moffat evaluator for that event and record it in diagnostics.
This prevents a visible hard cutoff in bright-star wings without silently
discarding their flux.
The selected tail budgets, maximum cache radius, and fallback count must be
reported with the render diagnostics. Their final numerical values are to be
chosen from the validation experiments, not inferred from a performance goal.
## Accumulation and parallel behavior
For every image event, compute color and final flux exactly as in the existing
path. Then multiply the scalar cached PSF weights by that color and flux and
add them to the worker's existing private HDR buffer. Preserve the current
private-HDR ownership, memory cap, reduction ordering, and serial fallback;
this plan does not introduce atomics, locks, or a different parallel
partitioning.
## Validation and benchmark
Add tests and a reproducible benchmark before making the lookup path default:
1. Compare cached and high-accuracy direct pixel-area integration for a grid
of FWHM, beta, phase, flux, and support-radius cases. Check kernel sum,
per-pixel error, and omitted-flux bound.
2. Translate a star in sub-pixel increments across pixel boundaries. Verify
continuous image values and absence of phase quantization jumps.
3. Test bright stars whose required support is larger than the ordinary cache
crop, including the direct-fallback case. Verify the configured tail-error
bound and no visible-radius discontinuity relative to the reference.
4. Compare serial and OpenMP outputs under the existing private-HDR contract.
Preserve the current expected identity/tolerance policy explicitly.
5. Benchmark the Schwarzschild 1080p, 60-degree, FWHM 1.0 all-sky case and
separately report cache-build time, number of image events, cached-splat
time, direct-fallback count, and total render time. Compare output against
the direct evaluator with the same error budgets.
The lookup path becomes the default only after it meets the agreed visual and
numerical tolerances and demonstrates a material speedup in the PSF-dominated
case. The direct evaluator remains a selectable regression reference.
## Implementation record (2026-08-28)
Implemented as one immutable `PsfKernelCache` per invocation, built after CLI
parsing and passed read-only through the existing frame splat workers. The
cache uses 64 by 64 phase intervals (65 nodes on each axis), 4-point
Gauss-Legendre pixel-area quadrature, and bilinear phase interpolation. The
`--psf-direct` regression mode uses an independent 8-point quadrature
reference; events whose requested support exceeds the cache use that path
automatically.
The selected HDR budgets are omitted flux `1e-6` and boundary contribution
`1e-7`; the former `1e-8` relative tail remains part of the support selection.
The cache reports build time, maximum radius, cached splat count, and direct
fallback count. `tests/test_frame.c` covers cache construction, phases near
pixel boundaries, comparison with the direct reference, and bright-event
fallback. The float-HDR default-PSF acceptance result and the 1080p full-sky
performance result are recorded in
`benchmarks/2mass_all_sky_psf_lookup_2026-08-28.md`.
+54 -19
View File
@@ -184,7 +184,9 @@ typedef struct {
int width, height;
double exposure, magnification;
const PointSpreadFunction *psf;
const PsfKernelCache *psf_cache;
size_t images;
size_t direct_fallbacks;
} TriangleSplatContext;
static int splat_catalog_tile(const Star *stars, size_t count,
@@ -218,9 +220,10 @@ static int splat_catalog_tile(const Star *stars, size_t count,
weights[1] * context->vertex[1]->log_frequency_ratio +
weights[2] * context->vertex[2]->log_frequency_ratio;
const LinearRgb color = blackbody_to_linear_rgb(star->temperature_K * exp(log_g));
splat_moffat(context->hdr, context->width, context->height, image_x, image_y,
color, context->exposure * star->amplitude * context->magnification,
context->psf);
context->direct_fallbacks += splat_moffat_cached(
context->hdr, context->width, context->height, image_x, image_y, color,
context->exposure * star->amplitude * context->magnification,
context->psf, context->psf_cache);
++context->images;
}
return 0;
@@ -230,8 +233,10 @@ static size_t splat_catalog_triangles(const FrameLensMesh *mesh,
StarCatalog *catalog, double *hdr,
int width, int height, double exposure,
const PointSpreadFunction *psf,
const PsfKernelCache *psf_cache,
size_t first_triangle,
size_t last_triangle) {
size_t last_triangle,
size_t *direct_fallbacks) {
size_t images = 0;
for (size_t t = first_triangle; t < last_triangle; ++t) {
const LensVertex *vertex[3];
@@ -251,10 +256,11 @@ static size_t splat_catalog_triangles(const FrameLensMesh *mesh,
.triangle = &mesh->triangles[t], .hdr = hdr,
.width = width, .height = height,
.exposure = exposure, .magnification = magnification,
.psf = psf};
.psf = psf, .psf_cache = psf_cache};
if (catalog_visit_source_triangle(catalog, direction, 0, splat_catalog_tile,
&context) == 0)
images += context.images;
*direct_fallbacks += context.direct_fallbacks;
}
return images;
}
@@ -283,8 +289,10 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
StarCatalog *catalog, double *hdr, int width,
int height, double exposure,
const PointSpreadFunction *psf,
const PsfKernelCache *psf_cache,
int catalog_load_workers,
CatalogPrefetchStats *prefetch_stats) {
CatalogPrefetchStats *prefetch_stats,
PsfSplatStats *psf_stats) {
if (mesh == NULL || catalog == NULL || hdr == NULL || exposure <= 0.0 ||
psf == NULL || width <= 0 || height <= 0 || catalog_load_workers <= 0)
return 0;
@@ -292,25 +300,45 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
/* A bounded parallel read phase completes before splatting. Its serial cache
* commit leaves immutable tile data for the OpenMP splat workers. */
prefetch_catalog_for_mesh(mesh, catalog, catalog_load_workers, prefetch_stats);
if (psf_stats != NULL)
*psf_stats = (PsfSplatStats){0};
const size_t pixel_count = (size_t)width * height * 3;
if (pixel_count > SIZE_MAX / sizeof(double) ||
pixel_count * sizeof(double) > FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES / 2)
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
psf, 0, mesh->triangle_count);
{
size_t fallbacks = 0;
const size_t images = splat_catalog_triangles(mesh, catalog, hdr, width, height,
exposure, psf, psf_cache, 0,
mesh->triangle_count, &fallbacks);
if (psf_stats != NULL) *psf_stats = (PsfSplatStats){images - fallbacks, fallbacks};
return images;
}
const size_t buffer_bytes = pixel_count * sizeof(double);
size_t worker_count = FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES / buffer_bytes;
const int max_threads = omp_get_max_threads();
if (worker_count > (size_t)max_threads)
worker_count = (size_t)max_threads;
if (worker_count < 2 || worker_count > INT_MAX)
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
psf, 0, mesh->triangle_count);
{
size_t fallbacks = 0;
const size_t images = splat_catalog_triangles(mesh, catalog, hdr, width, height,
exposure, psf, psf_cache, 0,
mesh->triangle_count, &fallbacks);
if (psf_stats != NULL) *psf_stats = (PsfSplatStats){images - fallbacks, fallbacks};
return images;
}
double **private_hdr = calloc(worker_count, sizeof *private_hdr);
if (private_hdr == NULL)
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
psf, 0, mesh->triangle_count);
{
size_t fallbacks = 0;
const size_t images = splat_catalog_triangles(mesh, catalog, hdr, width, height,
exposure, psf, psf_cache, 0,
mesh->triangle_count, &fallbacks);
if (psf_stats != NULL) *psf_stats = (PsfSplatStats){images - fallbacks, fallbacks};
return images;
}
size_t allocated = 0;
for (; allocated < worker_count; ++allocated) {
private_hdr[allocated] = calloc(pixel_count, sizeof **private_hdr);
@@ -321,12 +349,17 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
while (allocated > 0)
free(private_hdr[--allocated]);
free(private_hdr);
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
psf, 0, mesh->triangle_count);
size_t fallbacks = 0;
const size_t images = splat_catalog_triangles(mesh, catalog, hdr, width, height,
exposure, psf, psf_cache, 0,
mesh->triangle_count, &fallbacks);
if (psf_stats != NULL)
*psf_stats = (PsfSplatStats){images - fallbacks, fallbacks};
return images;
}
size_t images = 0;
#pragma omp parallel num_threads((int)worker_count) reduction(+ : images)
size_t images = 0, direct_fallbacks = 0;
#pragma omp parallel num_threads((int)worker_count) reduction(+ : images, direct_fallbacks)
{
const size_t worker = (size_t)omp_get_thread_num();
/* Source density and lens magnification can vary by orders of magnitude
@@ -335,9 +368,9 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
* idle. Each worker still owns its HDR buffer exclusively. */
#pragma omp for schedule(dynamic, 1)
for (size_t triangle = 0; triangle < mesh->triangle_count; ++triangle)
images += splat_catalog_triangles(mesh, catalog, private_hdr[worker],
width, height, exposure, psf, triangle,
triangle + 1);
images += splat_catalog_triangles(mesh, catalog, private_hdr[worker], width,
height, exposure, psf, psf_cache, triangle,
triangle + 1, &direct_fallbacks);
}
#pragma omp parallel for schedule(static)
for (size_t pixel = 0; pixel < pixel_count; ++pixel)
@@ -346,6 +379,8 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
for (size_t worker = 0; worker < worker_count; ++worker)
free(private_hdr[worker]);
free(private_hdr);
if (psf_stats != NULL)
*psf_stats = (PsfSplatStats){images - direct_fallbacks, direct_fallbacks};
return images;
}
+3 -1
View File
@@ -39,8 +39,10 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
StarCatalog *catalog, double *hdr, int width,
int height, double exposure,
const PointSpreadFunction *psf,
const PsfKernelCache *psf_cache,
int catalog_load_workers,
CatalogPrefetchStats *prefetch_stats);
CatalogPrefetchStats *prefetch_stats,
PsfSplatStats *psf_stats);
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
int height, double gray, double opacity);
void frame_lens_mesh_destroy(FrameLensMesh *mesh);
+39 -2
View File
@@ -18,13 +18,18 @@ typedef struct {
int width, height;
int coarse_cell_pixels;
int draw_mesh;
int psf_direct;
double horizontal_fov_deg, look_ra_deg, look_dec_deg, exposure;
double observer_radius;
double observer_inward_speed;
PointSpreadFunction psf;
PsfKernelCache psf_cache;
const char *catalog_path;
const char *all_sky_catalog_path;
const char *output_path;
#ifdef ENABLE_HDR_DEBUG
const char *hdr_output_path;
#endif
const char *observer_track_path;
const char *frames_dir;
const char *frames_prefix;
@@ -126,6 +131,10 @@ static int parse_args(int argc, char **argv, Settings *s,
s->all_sky_catalog_path = argv[++i];
else if (!strcmp(argv[i], "--output") && i + 1 < argc)
s->output_path = argv[++i];
#ifdef ENABLE_HDR_DEBUG
else if (!strcmp(argv[i], "--hdr-output") && i + 1 < argc)
s->hdr_output_path = argv[++i];
#endif
else if (!strcmp(argv[i], "--width") && i + 1 < argc &&
!parse_int(argv[++i], &s->width)) {
} else if (!strcmp(argv[i], "--height") && i + 1 < argc &&
@@ -150,6 +159,8 @@ static int parse_args(int argc, char **argv, Settings *s,
!parse_positive(argv[++i], &s->psf.fwhm_pixels)) {
} else if (!strcmp(argv[i], "--psf-moffat-beta") && i + 1 < argc &&
!parse_moffat_beta(argv[++i], &s->psf.moffat_beta)) {
} else if (!strcmp(argv[i], "--psf-direct")) {
s->psf_direct = 1;
} else if (!strcmp(argv[i], "--write-catalog") && i + 1 < argc)
*write_path = argv[++i];
else if (!strcmp(argv[i], "--observer-track") && i + 1 < argc)
@@ -219,15 +230,26 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
return -1;
}
CatalogPrefetchStats prefetch = {0};
PsfSplatStats psf_stats = {0};
size_t images = frame_splat_catalog(
&mesh, catalog, hdr, s->width, s->height, s->exposure, &s->psf,
s->catalog_load_workers, &prefetch);
&s->psf_cache, s->catalog_load_workers, &prefetch, &psf_stats);
if (s->draw_mesh)
frame_draw_mesh(&mesh, hdr, s->width, s->height, 0.5, 0.5);
#ifdef ENABLE_HDR_DEBUG
if (s->hdr_output_path != NULL &&
write_hdr_pfm(s->hdr_output_path, hdr, s->width, s->height)) {
perror(s->hdr_output_path);
frame_lens_mesh_destroy(&mesh);
free(hdr);
return -1;
}
#endif
int result = write_tonemapped_image(output_path, hdr, s->width, s->height);
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s)\n",
images, catalog->count, output_path,
result == 0 ? "ok" : "write failed");
psf_kernel_cache_report(&s->psf_cache, &psf_stats, stderr);
if (catalog->kind == STAR_CATALOG_ALL_SKY)
fprintf(stderr,
"Catalog prefetch: %zu requested, %zu newly loaded (%zu stars), "
@@ -319,15 +341,17 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
goto done;
}
CatalogPrefetchStats prefetch = {0};
PsfSplatStats psf_stats = {0};
const size_t images = frame_splat_catalog(
&movie.frames[i].mesh, catalog, hdr, s->width, s->height, s->exposure,
&s->psf, s->catalog_load_workers, &prefetch);
&s->psf, &s->psf_cache, s->catalog_load_workers, &prefetch, &psf_stats);
if (s->draw_mesh)
frame_draw_mesh(&movie.frames[i].mesh, hdr, s->width, s->height, 0.5, 0.5);
const int write_result = write_tonemapped_image(output_path, hdr, s->width, s->height);
free(hdr);
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s)\n",
images, catalog->count, output_path, write_result == 0 ? "ok" : "write failed");
psf_kernel_cache_report(&s->psf_cache, &psf_stats, stderr);
if (catalog->kind == STAR_CATALOG_ALL_SKY)
fprintf(stderr,
"Catalog prefetch: %zu requested, %zu newly loaded (%zu stars), "
@@ -369,6 +393,10 @@ int main(int argc, char **argv) {
"N] [--fov-deg D] [--look-ra-deg D] [--look-dec-deg D] "
"[--exposure E] [--observer-radius R] [--observer-inward-speed V] "
"[--psf-fwhm-pixels N] [--psf-moffat-beta N] "
"[--psf-direct] "
#ifdef ENABLE_HDR_DEBUG
"[--hdr-output PATH] "
#endif
"[--coarse-cell-pixels N] [--draw-mesh] [--write-catalog PATH] "
"[--catalog-load-workers N] "
"[--observer-track PATH --frames-dir DIR --frames-prefix NAME "
@@ -384,6 +412,12 @@ int main(int argc, char **argv) {
return write_minkowski_accel_track(&settings) == 0
? 0
: (perror(settings.write_minkowski_accel_track_path), 1);
#ifdef ENABLE_HDR_DEBUG
if (settings.frames_dir != NULL && settings.hdr_output_path != NULL) {
fputs("--hdr-output is available only for a single-frame render.\n", stderr);
return 2;
}
#endif
StarCatalog catalog = {0};
if (settings.all_sky_catalog_path != NULL) {
if (catalog_load_all_sky(&catalog, settings.all_sky_catalog_path)) {
@@ -404,10 +438,13 @@ int main(int argc, char **argv) {
catalog_destroy(&catalog);
return 1;
}
if (!settings.psf_direct && psf_kernel_cache_init(&settings.psf_cache, &settings.psf))
fputs("PSF cache construction failed; using direct evaluator.\n", stderr);
int result = settings.frames_dir != NULL
? render_movie(&settings, &catalog, &spacetime)
: render_frame(&settings, &catalog, &spacetime);
spacetime_destroy(&spacetime);
catalog_destroy(&catalog);
psf_kernel_cache_destroy(&settings.psf_cache);
return result == 0 ? 0 : 1;
}
+291 -29
View File
@@ -1,9 +1,11 @@
#include "optics.h"
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef ENABLE_PNG
#include <png.h>
@@ -76,39 +78,272 @@ LinearRgb blackbody_to_linear_rgb(double temperature_K)
0.0, INFINITY)};
}
enum {
PSF_PHASE_RESOLUTION = 64,
PSF_MAX_CACHE_RADIUS_PIXELS = 48,
PSF_QUADRATURE_ORDER = 4,
};
/* These are display-space HDR error budgets, deliberately independent of a
* relative flux cut. The historical 1e-8 relative tail remains a floor for
* ordinary images; brighter images grow their support or use the reference
* fallback rather than acquiring a visible clipped wing. */
static const double psf_relative_tail_fraction = 1e-8;
static const double psf_tail_absolute_hdr_budget = 1e-6;
static const double psf_boundary_hdr_budget = 1e-7;
static const double pi = 3.14159265358979323846;
static const double gauss4_x[PSF_QUADRATURE_ORDER] = {
-0.8611363115940526, -0.3399810435848563,
0.3399810435848563, 0.8611363115940526};
static const double gauss4_w[PSF_QUADRATURE_ORDER] = {
0.3478548451374539, 0.6521451548625461,
0.6521451548625461, 0.3478548451374539};
static const double gauss8_x[8] = {
-0.9602898564975363, -0.7966664774136267, -0.5255324099163290,
-0.1834346424956498, 0.1834346424956498, 0.5255324099163290,
0.7966664774136267, 0.9602898564975363};
static const double gauss8_w[8] = {
0.1012285362903763, 0.2223810344533745, 0.3137066458778873,
0.3626837833783620, 0.3626837833783620, 0.3137066458778873,
0.2223810344533745, 0.1012285362903763};
static int valid_psf(const PointSpreadFunction *psf)
{
return psf != NULL && isfinite(psf->fwhm_pixels) &&
isfinite(psf->moffat_beta) && psf->fwhm_pixels > 0.0 &&
psf->moffat_beta > 1.0;
}
static double moffat_alpha(const PointSpreadFunction *psf)
{
return psf->fwhm_pixels /
(2.0 * sqrt(pow(2.0, 1.0 / psf->moffat_beta) - 1.0));
}
static double moffat_support_radius(double alpha, double beta,
double scaled_flux)
{
const double normalization = scaled_flux * (beta - 1.0) / (pi * alpha * alpha);
const double relative_tail = fmin(psf_relative_tail_fraction,
psf_tail_absolute_hdr_budget / scaled_flux);
const double tail_radius = alpha * sqrt(pow(relative_tail,
1.0 / (1.0 - beta)) - 1.0);
const double boundary_ratio = psf_boundary_hdr_budget / normalization;
const double boundary_radius = boundary_ratio >= 1.0 ? 0.0 : alpha * sqrt(
pow(boundary_ratio, -1.0 / beta) - 1.0);
return fmax(tail_radius, boundary_radius);
}
static double moffat_pixel_integral_quadrature(double alpha, double beta,
double pixel_x, double pixel_y,
double star_x, double star_y,
const double *nodes,
const double *weights, int order)
{
const double normalization = (beta - 1.0) / (pi * alpha * alpha);
double sum = 0.0;
for (int iy = 0; iy < order; ++iy)
for (int ix = 0; ix < order; ++ix) {
const double sx = pixel_x + 0.5 + 0.5 * nodes[ix] - star_x;
const double sy = pixel_y + 0.5 + 0.5 * nodes[iy] - star_y;
sum += 0.25 * weights[ix] * weights[iy] * normalization *
pow(1.0 + (sx * sx + sy * sy) / (alpha * alpha), -beta);
}
return sum;
}
static double moffat_pixel_integral_cached(double alpha, double beta,
double pixel_x, double pixel_y,
double star_x, double star_y)
{
return moffat_pixel_integral_quadrature(alpha, beta, pixel_x, pixel_y,
star_x, star_y, gauss4_x, gauss4_w, 4);
}
static double moffat_pixel_integral_reference(double alpha, double beta,
double pixel_x, double pixel_y,
double star_x, double star_y)
{
return moffat_pixel_integral_quadrature(alpha, beta, pixel_x, pixel_y,
star_x, star_y, gauss8_x, gauss8_w, 8);
}
static size_t kernel_index(const PsfKernelCache *cache, int phase_x,
int phase_y, int offset_x, int offset_y)
{
const size_t nodes = (size_t)cache->phase_resolution + 1;
const size_t side = (size_t)cache->radius_pixels * 2 + 1;
return (((size_t)phase_y * nodes + phase_x) * side +
(size_t)(offset_y + cache->radius_pixels)) * side +
(size_t)(offset_x + cache->radius_pixels);
}
void psf_kernel_cache_destroy(PsfKernelCache *cache)
{
if (cache == NULL)
return;
free(cache->weights);
*cache = (PsfKernelCache){0};
}
int psf_kernel_cache_init(PsfKernelCache *cache, const PointSpreadFunction *psf)
{
if (cache == NULL || !valid_psf(psf))
return -1;
psf_kernel_cache_destroy(cache);
const double alpha = moffat_alpha(psf);
const double requested_radius = moffat_support_radius(alpha, psf->moffat_beta, 1.0);
if (!isfinite(requested_radius) || requested_radius <= 0.0)
return -1;
const int radius = (int)fmin((double)PSF_MAX_CACHE_RADIUS_PIXELS,
ceil(requested_radius) + 1.0);
const size_t nodes = PSF_PHASE_RESOLUTION + 1u;
const size_t side = (size_t)radius * 2 + 1u;
if (nodes > SIZE_MAX / nodes || nodes * nodes > SIZE_MAX / side ||
nodes * nodes * side > SIZE_MAX / side ||
nodes * nodes * side * side > SIZE_MAX / sizeof(float))
return -1;
const size_t count = nodes * nodes * side * side;
float *weights = malloc(count * sizeof *weights);
if (weights == NULL)
return -1;
const clock_t start = clock();
PsfKernelCache building = {.fwhm_pixels = psf->fwhm_pixels,
.moffat_beta = psf->moffat_beta,
.alpha_pixels = alpha,
.max_radius_pixels = radius,
.weights = weights,
.phase_resolution = PSF_PHASE_RESOLUTION,
.radius_pixels = radius};
for (int phase_y = 0; phase_y <= PSF_PHASE_RESOLUTION; ++phase_y)
for (int phase_x = 0; phase_x <= PSF_PHASE_RESOLUTION; ++phase_x) {
const double star_x = (double)phase_x / PSF_PHASE_RESOLUTION;
const double star_y = (double)phase_y / PSF_PHASE_RESOLUTION;
double sum = 0.0;
for (int offset_y = -radius; offset_y <= radius; ++offset_y)
for (int offset_x = -radius; offset_x <= radius; ++offset_x) {
const size_t index = kernel_index(&building, phase_x, phase_y,
offset_x, offset_y);
const double weight = moffat_pixel_integral_cached(
alpha, psf->moffat_beta, offset_x, offset_y, star_x, star_y);
weights[index] = (float)weight;
sum += weight;
}
if (!(sum > 0.0) || !isfinite(sum)) {
free(weights);
return -1;
}
for (int offset_y = -radius; offset_y <= radius; ++offset_y)
for (int offset_x = -radius; offset_x <= radius; ++offset_x) {
const size_t index = kernel_index(&building, phase_x, phase_y,
offset_x, offset_y);
weights[index] = (float)(weights[index] / sum);
}
}
building.build_seconds = (double)(clock() - start) / CLOCKS_PER_SEC;
building.ready = 1;
*cache = building;
return 0;
}
void psf_kernel_cache_report(const PsfKernelCache *cache,
const PsfSplatStats *stats, FILE *stream)
{
if (stream == NULL)
return;
if (cache == NULL || !cache->ready) {
fprintf(stream, "PSF cache: disabled; cached splats 0, direct fallbacks %zu\n",
stats == NULL ? 0u : stats->direct_fallbacks);
return;
}
fprintf(stream, "PSF cache: 64x64 phases, radius %.0f px, tail abs %.0e, "
"boundary %.0e, build %.3f s; cached splats %zu, direct fallbacks %zu\n",
cache->max_radius_pixels, psf_tail_absolute_hdr_budget,
psf_boundary_hdr_budget, cache->build_seconds,
stats == NULL ? 0u : stats->cached_splats,
stats == NULL ? 0u : stats->direct_fallbacks);
}
void splat_moffat_direct(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf)
{
if (hdr == NULL || width <= 0 || height <= 0 || flux <= 0.0 || !valid_psf(psf))
return;
const double alpha = moffat_alpha(psf);
if (!isfinite(flux))
return;
const double support_radius = moffat_support_radius(alpha, psf->moffat_beta, flux);
const int min_x = fmax(0.0, floor(x - support_radius));
const int max_x = fmin((double)width - 1.0, ceil(x + support_radius));
const int min_y = fmax(0.0, floor(y - support_radius));
const int max_y = fmin((double)height - 1.0, ceil(y + support_radius));
for (int py = min_y; py <= max_y; ++py) for (int px = min_x; px <= max_x; ++px) {
if (px < 0 || px >= width || py < 0 || py >= height)
continue;
const double dx = (px + 0.5) - x, dy = (py + 0.5) - y;
if (dx * dx + dy * dy > support_radius * support_radius)
continue;
const double weight = moffat_pixel_integral_reference(alpha, psf->moffat_beta,
px, py, x, y);
double *pixel = &hdr[3 * (py * width + px)];
pixel[0] += color.r * flux * weight;
pixel[1] += color.g * flux * weight;
pixel[2] += color.b * flux * weight;
}
}
int splat_moffat_cached(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf,
const PsfKernelCache *cache)
{
if (hdr == NULL || width <= 0 || height <= 0 || flux <= 0.0 || !valid_psf(psf))
return 1;
const double alpha = moffat_alpha(psf);
const double support_radius = moffat_support_radius(alpha, psf->moffat_beta,
flux);
if (cache == NULL || !cache->ready || cache->fwhm_pixels != psf->fwhm_pixels ||
cache->moffat_beta != psf->moffat_beta || !isfinite(support_radius) ||
support_radius > cache->max_radius_pixels) {
splat_moffat_direct(hdr, width, height, x, y, color, flux, psf);
return 1;
}
const double base_x = floor(x), base_y = floor(y);
const double fx = x - base_x, fy = y - base_y;
const double phase_x = fx * cache->phase_resolution;
const double phase_y = fy * cache->phase_resolution;
const int x0 = (int)floor(phase_x), y0 = (int)floor(phase_y);
const int x1 = x0 + 1, y1 = y0 + 1;
const double tx = phase_x - x0, ty = phase_y - y0;
const int support = (int)ceil(support_radius);
for (int offset_y = -support; offset_y <= support; ++offset_y)
for (int offset_x = -support; offset_x <= support; ++offset_x) {
const int px = (int)base_x + offset_x, py = (int)base_y + offset_y;
if (px < 0 || px >= width || py < 0 || py >= height)
continue;
const double dx = offset_x + 0.5 - fx, dy = offset_y + 0.5 - fy;
if (dx * dx + dy * dy > support_radius * support_radius)
continue;
const double w00 = cache->weights[kernel_index(cache, x0, y0, offset_x, offset_y)];
const double w10 = cache->weights[kernel_index(cache, x1, y0, offset_x, offset_y)];
const double w01 = cache->weights[kernel_index(cache, x0, y1, offset_x, offset_y)];
const double w11 = cache->weights[kernel_index(cache, x1, y1, offset_x, offset_y)];
const double weight = (1.0 - ty) * ((1.0 - tx) * w00 + tx * w10) +
ty * ((1.0 - tx) * w01 + tx * w11);
double *pixel = &hdr[3 * (py * width + px)];
pixel[0] += color.r * flux * weight;
pixel[1] += color.g * flux * weight;
pixel[2] += color.b * flux * weight;
}
return 0;
}
void splat_moffat(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf)
{
/* The tail omitted outside this radius contains 1e-8 of the Moffat's
* total flux. Unlike the old fixed 3-sigma box, this is both circular and
* far below the displayed HDR precision for the chosen beta. */
const double tail_fraction = 1e-8;
if (hdr == NULL || psf == NULL || flux <= 0.0 ||
psf->fwhm_pixels <= 0.0 || psf->moffat_beta <= 1.0)
return;
const double beta = psf->moffat_beta;
const double alpha = psf->fwhm_pixels /
(2.0 * sqrt(pow(2.0, 1.0 / beta) - 1.0));
const double support_radius = alpha * sqrt(
pow(tail_fraction, 1.0 / (1.0 - beta)) - 1.0);
const double support_radius_squared = support_radius * support_radius;
const int min_x = (int)floor(x - support_radius);
const int max_x = (int)ceil(x + support_radius);
const int min_y = (int)floor(y - support_radius);
const int max_y = (int)ceil(y + support_radius);
const double normalization = flux * (beta - 1.0) /
(3.14159265358979323846 * alpha * alpha);
for (int py = min_y; py <= max_y; ++py) for (int px = min_x; px <= max_x; ++px) {
if (px < 0 || px >= width || py < 0 || py >= height) continue;
double dx = (px + 0.5) - x, dy = (py + 0.5) - y;
const double radius_squared = dx * dx + dy * dy;
if (radius_squared > support_radius_squared) continue;
const double w = normalization *
pow(1.0 + radius_squared / (alpha * alpha), -beta);
double *pixel = &hdr[3 * (py * width + px)];
pixel[0] += color.r * w; pixel[1] += color.g * w; pixel[2] += color.b * w;
}
splat_moffat_direct(hdr, width, height, x, y, color, flux, psf);
}
static unsigned char tonemap_channel(double hdr_value)
@@ -186,3 +421,30 @@ int write_tonemapped_image(const char *path, const double *hdr, int width, int h
return -1;
#endif
}
#ifdef ENABLE_HDR_DEBUG
int write_hdr_pfm(const char *path, const double *hdr, int width, int height)
{
if (path == NULL || hdr == NULL || width <= 0 || height <= 0)
return -1;
FILE *file = fopen(path, "wb");
if (file == NULL)
return -1;
const uint16_t endian_probe = 1;
const char *scale = *(const unsigned char *)&endian_probe == 1 ? "-1.0" : "1.0";
int result = fprintf(file, "PF\n%d %d\n%s\n", width, height, scale) < 0 ? -1 : 0;
/* PFM rows are stored bottom-to-top. Its negative scale declares little-endian
* float samples, avoiding an unnecessary byte swap on the normal test host. */
for (int row = height - 1; result == 0 && row >= 0; --row)
for (int column = 0; column < width * 3; ++column) {
const float sample = (float)hdr[(size_t)row * width * 3 + column];
if (fwrite(&sample, sizeof sample, 1, file) != 1) {
result = -1;
break;
}
}
if (fclose(file) != 0)
result = -1;
return result;
}
#endif
+37
View File
@@ -1,20 +1,57 @@
#ifndef OPTICS_H
#define OPTICS_H
#include <stddef.h>
#include <stdio.h>
typedef struct { double r, g, b; } LinearRgb;
typedef struct {
double fwhm_pixels;
double moffat_beta;
} PointSpreadFunction;
/* One immutable process-wide kernel for the one PSF parameter pair accepted
* by the current renderer. Its storage remains private to optics.c. */
typedef struct {
double fwhm_pixels, moffat_beta, alpha_pixels;
double max_radius_pixels, build_seconds;
float *weights;
int phase_resolution, radius_pixels;
int ready;
} PsfKernelCache;
typedef struct {
size_t cached_splats;
size_t direct_fallbacks;
} PsfSplatStats;
/* Integrate a Planck spectrum into absolute linear-sRGB spectral radiance
* (W m^-2 sr^-1), before catalog amplitude and display exposure. */
LinearRgb blackbody_to_linear_rgb(double temperature_K);
int psf_kernel_cache_init(PsfKernelCache *cache,
const PointSpreadFunction *psf);
void psf_kernel_cache_destroy(PsfKernelCache *cache);
void psf_kernel_cache_report(const PsfKernelCache *cache,
const PsfSplatStats *stats, FILE *stream);
/* Reference implementation: pixel-area-integrated Moffat with the same tail
* budgets as the cache-aware renderer. */
void splat_moffat_direct(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf);
void splat_moffat(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf);
/* Returns nonzero when this event used the direct reference fallback. */
int splat_moffat_cached(double *hdr, int width, int height, double x, double y,
LinearRgb color, double flux,
const PointSpreadFunction *psf,
const PsfKernelCache *cache);
/* Writes PNG when built with libpng; non-libpng builds use PPM fallback. */
int write_tonemapped_image(const char *path, const double *hdr, int width,
int height);
#ifdef ENABLE_HDR_DEBUG
/* Test-build-only: writes the pre-tone-mapping framebuffer as RGB float PFM. */
int write_hdr_pfm(const char *path, const double *hdr, int width, int height);
#endif
#endif
+54 -4
View File
@@ -27,7 +27,7 @@ int main(void) {
goto done;
const size_t images =
frame_splat_catalog(&mesh, &catalog, hdr, width, height, test_exposure,
&psf, 1, NULL);
&psf, NULL, 1, NULL, NULL);
if (images != 1 || hdr[3 * (50 * width + 50)] <= 0.0) {
fputs("flat-space inverse lens-map regression failed\n", stderr);
goto done;
@@ -44,10 +44,10 @@ int main(void) {
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, 1, NULL);
&mesh, &catalog, serial_hdr, width, height, test_exposure, &psf, NULL, 1, NULL, NULL);
omp_set_num_threads(4);
const size_t parallel_images = frame_splat_catalog(
&mesh, &catalog, parallel_hdr, width, height, test_exposure, &psf, 1, NULL);
&mesh, &catalog, parallel_hdr, width, height, test_exposure, &psf, NULL, 1, 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]) >
@@ -83,6 +83,56 @@ int main(void) {
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)) {
free(cached_hdr);
free(reference_hdr);
psf_kernel_cache_destroy(&cache);
fputs("PSF cache construction regression failed\n", stderr);
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) != 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);
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;
}
}
/* 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) {
fputs("bright PSF direct-fallback regression failed\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);
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);
@@ -102,7 +152,7 @@ int main(void) {
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, 1, NULL) != 1) {
test_exposure, &psf, NULL, 1, NULL, NULL) != 1) {
fputs("fine source-triangle containment regression failed\n", stderr);
frame_lens_mesh_destroy(&fine_mesh);
goto done;