Compare commits

...
2 Commits
Author SHA1 Message Date
wyj 7399eb6904 Tests: Use image-neutral wording in camera CLI logs
The camera CLI test now runs for both PNG and PPM builds, so the single/movie
comparison messages should say "image" rather than "PNG".
2026-09-26 17:02:31 -04:00
wyj 2a0197229a Feat: Write mesh overlay as a separate <stem>_mesh image
--draw-mesh no longer modifies the primary render.  The clean HDR FITS and
tone-mapped image are written first, then the final image-plane mesh is
overlaid in place on the already-consumed HDR buffer and saved as a
<stem>_mesh sibling.  This applies uniformly to single-frame, movie-frame,
and imported lens-map renders, with derived paths validated before catalog,
spacetime, and render initialization.

Dummy PSF backends still write nothing and report ignoring --draw-mesh.  The
reference-image target now uses the configured image extension so the
ENABLE_PNG=0 test path passes, and the CLI tests cover clean-main identity,
sibling naming, PATH_MAX overflow, and zero-tolerance HDR comparison.

README, README.zh-CN, and usage.md describe the new naming; the mesh reference
asset is renamed to match.
2026-09-26 17:01:25 -04:00
7 changed files with 269 additions and 70 deletions

No files matched your search

+8 -4
View File
@@ -173,8 +173,11 @@ blueshift pushes both the 3000 K and 12000 K test stars toward blue-white.
### Example: Synthetic test grid with mesh overlay ### Example: Synthetic test grid with mesh overlay
This example uses `assets/sky_grid_5deg.csv` to inspect lensing and adaptive This example uses `assets/sky_grid_5deg.csv` to inspect lensing and adaptive
mesh refinement in Schwarzschild spacetime. `--draw-mesh` overlays the final mesh refinement in Schwarzschild spacetime. The main output is the clean
image-plane triangles. tone-mapped image; `--draw-mesh` additionally writes the final image-plane
triangles, so one command produces both
`output/imgs/schwarzschild_test_grid.png` (no mesh) and
`output/imgs/schwarzschild_test_grid_mesh.png` (mesh overlay).
```sh ```sh
mkdir -p output/imgs mkdir -p output/imgs
@@ -190,9 +193,10 @@ mkdir -p output/imgs
--output output/imgs/schwarzschild_test_grid.png --output output/imgs/schwarzschild_test_grid.png
``` ```
[![Synthetic stellar grid lensed by a Schwarzschild black hole, with adaptive mesh overlay](assets/images/schwarzschild_test_grid.png)](assets/images/schwarzschild_test_grid.png) [![Synthetic stellar grid lensed by a Schwarzschild black hole, with adaptive mesh overlay](assets/images/schwarzschild_test_grid_mesh.png)](assets/images/schwarzschild_test_grid_mesh.png)
*4K test-grid reference image. Click to view at full resolution.* *4K test-grid reference image showing the `_mesh.png` overlay. Click to view at
full resolution.*
### Freely falling Schwarzschild movie camera ### Freely falling Schwarzschild movie camera
+3 -3
View File
@@ -115,7 +115,7 @@ mkdir -p output/imgs
### 示例:叠加网格的合成测试星表 ### 示例:叠加网格的合成测试星表
这个示例使用 `assets/sky_grid_5deg.csv` 检查 Schwarzschild 时空中的引力透镜效果与自适应网格细分。`--draw-mesh` 会叠加显示最终的像平面三角网格。 这个示例使用 `assets/sky_grid_5deg.csv` 检查 Schwarzschild 时空中的引力透镜效果与自适应网格细分。主输出是不带网格的成品图;`--draw-mesh` 会额外写出最终的像平面三角网格,因此同一次命令会同时生成 `output/imgs/schwarzschild_test_grid.png`(无网格)和 `output/imgs/schwarzschild_test_grid_mesh.png`(网格叠加)。
```sh ```sh
mkdir -p output/imgs mkdir -p output/imgs
@@ -131,9 +131,9 @@ mkdir -p output/imgs
--output output/imgs/schwarzschild_test_grid.png --output output/imgs/schwarzschild_test_grid.png
``` ```
[![Schwarzschild 黑洞对合成恒星网格的透镜效果,叠加自适应网格](assets/images/schwarzschild_test_grid.png)](assets/images/schwarzschild_test_grid.png) [![Schwarzschild 黑洞对合成恒星网格的透镜效果,叠加自适应网格](assets/images/schwarzschild_test_grid_mesh.png)](assets/images/schwarzschild_test_grid_mesh.png)
*4K 测试网格参考图像。点击查看完整分辨率。* *4K 测试网格参考图像,展示 `_mesh.png` 网格叠加结果。点击查看完整分辨率。*
### Schwarzschild 自由落体相机轨迹 ### Schwarzschild 自由落体相机轨迹
+2 -2
View File
@@ -9,8 +9,8 @@ test: test-reference-images
test-reference-images: $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/minkowski_ra1_dec1_640x360_HDR.fits $(REFERENCE_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits test-reference-images: $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/minkowski_ra1_dec1_640x360_HDR.fits $(REFERENCE_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits
$(MAKE) SPACETIME=minkowski ENABLE_HDR=1 backend $(MAKE) SPACETIME=minkowski ENABLE_HDR=1 backend
mkdir -p $(REFERENCE_TMP_DIR) mkdir -p $(REFERENCE_TMP_DIR)
OMP_NUM_THREADS=16 $(BUILD_DIR)/minkowski_sky --catalog assets/sky_grid_5deg.csv --output $(REFERENCE_TMP_DIR)/minkowski_ra1_dec1_640x360.png --hdr-output --width 640 --height 360 --fov-deg 30 --look-ra-deg 1 --look-dec-deg 1 --exposure 0.1 --observer-radius 30 --observer-velocity 0 0 0 --psf-fwhm-pixels 2.7 --psf-moffat-beta 4.5 --max-magnification 1e300 --max-cache-psf-flux 1 --psf-relative-tail 1e-8 --psf-min-y 0 --coarse-cell-pixels 16 --refine-max-level 0 --refine-angle-abs-deg 0.001 --refine-angle-rel 0.1 --refine-jacobian-min 1e-3 --refine-min-edge-pixels 0.5 --refine-min-area-pixels2 0.25 --catalog-load-workers 4 OMP_NUM_THREADS=16 $(BUILD_DIR)/minkowski_sky --catalog assets/sky_grid_5deg.csv --output $(REFERENCE_TMP_DIR)/minkowski_ra1_dec1_640x360.$(IMAGE_EXT) --hdr-output --width 640 --height 360 --fov-deg 30 --look-ra-deg 1 --look-dec-deg 1 --exposure 0.1 --observer-radius 30 --observer-velocity 0 0 0 --psf-fwhm-pixels 2.7 --psf-moffat-beta 4.5 --max-magnification 1e300 --max-cache-psf-flux 1 --psf-relative-tail 1e-8 --psf-min-y 0 --coarse-cell-pixels 16 --refine-max-level 0 --refine-angle-abs-deg 0.001 --refine-angle-rel 0.1 --refine-jacobian-min 1e-3 --refine-min-edge-pixels 0.5 --refine-min-area-pixels2 0.25 --catalog-load-workers 4
python3 $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/minkowski_ra1_dec1_640x360_HDR.fits $(REFERENCE_TMP_DIR)/minkowski_ra1_dec1_640x360_HDR.fits python3 $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/minkowski_ra1_dec1_640x360_HDR.fits $(REFERENCE_TMP_DIR)/minkowski_ra1_dec1_640x360_HDR.fits
$(MAKE) SPACETIME=schwarzschild ENABLE_HDR=1 backend $(MAKE) SPACETIME=schwarzschild ENABLE_HDR=1 backend
OMP_NUM_THREADS=16 $(BUILD_DIR)/schwarzschild_sky --catalog assets/sky_grid_5deg.csv --output $(REFERENCE_TMP_DIR)/schwarzschild_ra1_dec1_fov60_640x360.png --hdr-output --width 640 --height 360 --fov-deg 60 --look-ra-deg 1 --look-dec-deg 1 --exposure 0.1 --observer-radius 30 --observer-velocity 0 0 0 --psf-fwhm-pixels 2.7 --psf-moffat-beta 4.5 --max-magnification 1e300 --max-cache-psf-flux 1 --psf-relative-tail 1e-8 --psf-min-y 0 --coarse-cell-pixels 16 --refine-max-level 0 --refine-angle-abs-deg 0.001 --refine-angle-rel 0.1 --refine-jacobian-min 1e-3 --refine-min-edge-pixels 0.5 --refine-min-area-pixels2 0.25 --catalog-load-workers 4 OMP_NUM_THREADS=16 $(BUILD_DIR)/schwarzschild_sky --catalog assets/sky_grid_5deg.csv --output $(REFERENCE_TMP_DIR)/schwarzschild_ra1_dec1_fov60_640x360.$(IMAGE_EXT) --hdr-output --width 640 --height 360 --fov-deg 60 --look-ra-deg 1 --look-dec-deg 1 --exposure 0.1 --observer-radius 30 --observer-velocity 0 0 0 --psf-fwhm-pixels 2.7 --psf-moffat-beta 4.5 --max-magnification 1e300 --max-cache-psf-flux 1 --psf-relative-tail 1e-8 --psf-min-y 0 --coarse-cell-pixels 16 --refine-max-level 0 --refine-angle-abs-deg 0.001 --refine-angle-rel 0.1 --refine-jacobian-min 1e-3 --refine-min-edge-pixels 0.5 --refine-min-area-pixels2 0.25 --catalog-load-workers 4
python3 $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits $(REFERENCE_TMP_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits python3 $(FLOATDIFF_SCRIPT) $(REFERENCE_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits $(REFERENCE_TMP_DIR)/schwarzschild_ra1_dec1_fov60_640x360_HDR.fits
+132 -48
View File
@@ -159,18 +159,24 @@ static int validate_tonemapped_output_path(const char *path) {
return -1; return -1;
} }
/* Length of the path up to (but excluding) its final extension. Only the
* last path component is inspected, so dots inside directory names do not
* count, and a leading-dot basename such as ".png" has no extension. */
static size_t final_stem_length(const char *path) {
const char *filename = strrchr(path, '/');
const char *extension;
filename = filename == NULL ? path : filename + 1;
extension = strrchr(filename, '.');
return extension != NULL && extension != filename ? (size_t)(extension - path)
: strlen(path);
}
#ifdef ENABLE_HDR_OUTPUT #ifdef ENABLE_HDR_OUTPUT
static int make_hdr_output_path(char path[PATH_MAX], const char *output_path) { static int make_hdr_output_path(char path[PATH_MAX], const char *output_path) {
static const char hdr_suffix[] = "_HDR.fits"; static const char hdr_suffix[] = "_HDR.fits";
const char *filename = strrchr(output_path, '/'); const size_t stem_length = final_stem_length(output_path);
const char *extension;
size_t stem_length;
filename = filename == NULL ? output_path : filename + 1;
extension = strrchr(filename, '.');
stem_length = extension != NULL && extension != filename
? (size_t)(extension - output_path)
: strlen(output_path);
if (stem_length + sizeof hdr_suffix > PATH_MAX) if (stem_length + sizeof hdr_suffix > PATH_MAX)
return -1; return -1;
memcpy(path, output_path, stem_length); memcpy(path, output_path, stem_length);
@@ -179,6 +185,80 @@ static int make_hdr_output_path(char path[PATH_MAX], const char *output_path) {
} }
#endif #endif
/* Derives the mesh-overlay sibling of a tone-mapped output path by inserting
* "_mesh" before the final extension: image.png -> image_mesh.png. */
static int make_mesh_output_path(char path[PATH_MAX], const char *output_path) {
static const char mesh_infix[] = "_mesh";
const size_t stem_length = final_stem_length(output_path);
const char *suffix = output_path + stem_length;
const size_t suffix_length = strlen(suffix);
const size_t infix_length = sizeof mesh_infix - 1;
if (stem_length + infix_length + suffix_length + 1 > PATH_MAX)
return -1;
memcpy(path, output_path, stem_length);
memcpy(path + stem_length, mesh_infix, infix_length);
memcpy(path + stem_length + infix_length, suffix, suffix_length + 1);
return 0;
}
typedef struct {
const char *output_path;
char mesh_path[PATH_MAX];
int draw_mesh;
} FrameOutputPaths;
/* Fills in the plain and mesh-overlay output paths for one frame, returning
* -1 if a requested mesh sibling would overflow PATH_MAX. Callers choose the
* timing; main() separately pre-validates the single-frame path before
* expensive initialization. */
static int build_frame_output_paths(const Settings *s, const char *output_path,
FrameOutputPaths *paths) {
paths->output_path = output_path;
paths->draw_mesh = s->draw_mesh;
if (paths->draw_mesh && make_mesh_output_path(paths->mesh_path, output_path)) {
fprintf(stderr, "Mesh overlay output path is too long: %s\n", output_path);
return -1;
}
return 0;
}
/* Canonical output order for every frame: clean HDR, clean tone-mapped image,
* then the mesh overlay. The overlay reuses the already-consumed HDR buffer,
* so no second full-size framebuffer is allocated and nothing is re-rendered. */
static int write_frame_outputs(const Settings *s, const FrameLensMesh *mesh,
double *hdr, int width, int height, double fov_deg,
const FrameOutputPaths *paths, size_t images,
size_t stars, const char *note) {
#ifdef ENABLE_HDR_OUTPUT
if (s->write_hdr_output &&
write_hdr_fits(s->hdr_output_path, hdr, width, height, fov_deg)) {
fprintf(stderr, "Failed to write HDR FITS image: %s\n", s->hdr_output_path);
return -1;
}
#else
(void)s;
(void)fov_deg;
#endif
const int write_result =
write_tonemapped_image(paths->output_path, hdr, width, height);
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s%s)\n",
images, stars, paths->output_path,
write_result == 0 ? "ok" : "write failed", note);
if (write_result)
return -1;
if (paths->draw_mesh) {
frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5);
if (write_tonemapped_image(paths->mesh_path, hdr, width, height)) {
fprintf(stderr, "Failed to write mesh overlay image: %s\n",
paths->mesh_path);
return -1;
}
fprintf(stderr, "Wrote mesh overlay image: %s\n", paths->mesh_path);
}
return 0;
}
static int parse_args(int argc, char **argv, Settings *s, static int parse_args(int argc, char **argv, Settings *s,
const char **write_path) { const char **write_path) {
#ifdef ENABLE_PNG #ifdef ENABLE_PNG
@@ -415,8 +495,16 @@ static void print_help(const char *program) {
" --refine-angle-rel R Relative angular interpolation error limit (default: 0.1)\n" " --refine-angle-rel R Relative angular interpolation error limit (default: 0.1)\n"
" --refine-jacobian-min J Fold-refinement Jacobian threshold (default: 1e-3)\n" " --refine-jacobian-min J Fold-refinement Jacobian threshold (default: 1e-3)\n"
" --refine-min-edge-pixels P Stop refinement below this edge length (default: 0.5)\n" " --refine-min-edge-pixels P Stop refinement below this edge length (default: 0.5)\n"
" --refine-min-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n" " --refine-min-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n",
" --draw-mesh Draw the final lens mesh overlay (default: disabled)\n" stdout);
#ifdef ENABLE_PNG
fputs(" --draw-mesh Also write the final lens-mesh overlay as <output-stem>_mesh.png\n",
stdout);
#else
fputs(" --draw-mesh Also write the final lens-mesh overlay as <output-stem>_mesh.ppm\n",
stdout);
#endif
fputs(
"\nMovie and observer track:\n" "\nMovie and observer track:\n"
" --observer-track PATH Observer worldline/tetrad CSV for movie rendering (default: disabled)\n" " --observer-track PATH Observer worldline/tetrad CSV for movie rendering (default: disabled)\n"
" --movie-track-samples One frame per CSV row; ignores start-time/duration/fps (default: disabled)\n" " --movie-track-samples One frame per CSV row; ignores start-time/duration/fps (default: disabled)\n"
@@ -722,22 +810,15 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
free(hdr); free(hdr);
return -1; return -1;
} }
if (s->draw_mesh) FrameOutputPaths output_paths;
frame_draw_mesh(&mesh, hdr, s->width, s->height, 0.5, 0.5); if (build_frame_output_paths(s, output_path, &output_paths)) {
#ifdef ENABLE_HDR_OUTPUT
if (s->write_hdr_output &&
write_hdr_fits(s->hdr_output_path, hdr, s->width, s->height,
s->horizontal_fov_deg)) {
fprintf(stderr, "Failed to write HDR FITS image: %s\n", s->hdr_output_path);
frame_lens_mesh_destroy(&mesh); frame_lens_mesh_destroy(&mesh);
free(hdr); free(hdr);
return -1; return -1;
} }
#endif const int result = write_frame_outputs(
int result = write_tonemapped_image(output_path, hdr, s->width, s->height); s, &mesh, hdr, s->width, s->height, s->horizontal_fov_deg, &output_paths,
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s)\n", images, catalog->count, "");
images, catalog->count, output_path,
result == 0 ? "ok" : "write failed");
report_psf_splat(s, &psf_stats); report_psf_splat(s, &psf_stats);
frame_lens_mesh_destroy(&mesh); frame_lens_mesh_destroy(&mesh);
free(hdr); free(hdr);
@@ -897,11 +978,13 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
} }
for (size_t i = 0; i < movie.frame_count; ++i) { for (size_t i = 0; i < movie.frame_count; ++i) {
char output_path[PATH_MAX]; char output_path[PATH_MAX];
FrameOutputPaths output_paths;
if (frame_output_path(output_path, s, movie.frames[i].frame_id) ||
build_frame_output_paths(s, output_path, &output_paths))
goto done;
double *hdr = calloc((size_t)s->width * s->height * 3, sizeof *hdr); double *hdr = calloc((size_t)s->width * s->height * 3, sizeof *hdr);
if (hdr == NULL || frame_output_path(output_path, s, movie.frames[i].frame_id)) { if (hdr == NULL)
free(hdr);
goto done; goto done;
}
CatalogPrefetchStats prefetch = {0}; CatalogPrefetchStats prefetch = {0};
PsfSplatStats psf_stats = {0}; PsfSplatStats psf_stats = {0};
RenderProgress progress = { RenderProgress progress = {
@@ -925,12 +1008,10 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
free(hdr); free(hdr);
goto done; goto done;
} }
if (s->draw_mesh) const int write_result = write_frame_outputs(
frame_draw_mesh(&movie.frames[i].mesh, hdr, s->width, s->height, 0.5, 0.5); s, &movie.frames[i].mesh, hdr, s->width, s->height,
const int write_result = write_tonemapped_image(output_path, hdr, s->width, s->height); s->horizontal_fov_deg, &output_paths, images, catalog->count, "");
free(hdr); 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");
report_psf_splat(s, &psf_stats); report_psf_splat(s, &psf_stats);
if (write_result) if (write_result)
goto done; goto done;
@@ -982,6 +1063,9 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
for (size_t i = 0; i < map.frame_count; ++i) { for (size_t i = 0; i < map.frame_count; ++i) {
const char *output_path = s->output_path; const char *output_path = s->output_path;
char movie_path[PATH_MAX]; char movie_path[PATH_MAX];
#ifndef PSF_BACKEND_DUMMY
FrameOutputPaths output_paths;
#endif
if (map.frame_count > 1) { if (map.frame_count > 1) {
if (frame_output_path(movie_path, s, (size_t)map.frames[i].frame_id)) { if (frame_output_path(movie_path, s, (size_t)map.frames[i].frame_id)) {
fputs("Could not construct imported-map movie output path.\n", stderr); fputs("Could not construct imported-map movie output path.\n", stderr);
@@ -989,7 +1073,11 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
} }
output_path = movie_path; output_path = movie_path;
} }
#ifdef PSF_BACKEND_DUMMY #ifndef PSF_BACKEND_DUMMY
if (build_frame_output_paths(s, output_path, &output_paths)) {
result = -1; break;
}
#else
(void)output_path; (void)output_path;
#endif #endif
#ifdef PSF_BACKEND_DUMMY #ifdef PSF_BACKEND_DUMMY
@@ -1020,28 +1108,17 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
result = -1; result = -1;
break; break;
} }
if (s->draw_mesh)
#ifdef PSF_BACKEND_DUMMY
fputs("Dummy PSF backend ignores --draw-mesh.\n", stderr);
#else
frame_draw_mesh(&map.frames[i].mesh, hdr, map.width, map.height, 0.5, 0.5);
#endif
#ifndef PSF_BACKEND_DUMMY #ifndef PSF_BACKEND_DUMMY
#ifdef ENABLE_HDR_OUTPUT const int write_result = write_frame_outputs(
if (s->write_hdr_output && s, &map.frames[i].mesh, hdr, map.width, map.height,
write_hdr_fits(s->hdr_output_path, hdr, map.width, map.height, map.horizontal_fov_deg, &output_paths, images, catalog->count,
map.horizontal_fov_deg)) { "; imported lens map");
fprintf(stderr, "Failed to write HDR FITS image: %s\n", s->hdr_output_path);
free(hdr); result = -1; break;
}
#endif
const int write_result = write_tonemapped_image(output_path, hdr, map.width, map.height);
free(hdr); free(hdr);
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s; imported lens map)\n",
images, catalog->count, output_path, write_result == 0 ? "ok" : "write failed");
report_psf_splat(s, &psf_stats); report_psf_splat(s, &psf_stats);
if (write_result) { result = -1; break; } if (write_result) { result = -1; break; }
#else #else
if (s->draw_mesh)
fputs("Dummy PSF backend ignores --draw-mesh.\n", stderr);
free(hdr); free(hdr);
fprintf(stderr, fprintf(stderr,
"Dummy PSF classified %zu images from %zu catalog stars; no HDR, PNG, or PPM was written.\n", "Dummy PSF classified %zu images from %zu catalog stars; no HDR, PNG, or PPM was written.\n",
@@ -1162,6 +1239,13 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
#endif #endif
if (settings.draw_mesh && settings.frames_dir == NULL) {
char mesh_path[PATH_MAX];
if (make_mesh_output_path(mesh_path, settings.output_path)) {
fputs("Mesh overlay output path is too long.\n", stderr);
return 2;
}
}
if (resolve_camera(&settings)) return 2; if (resolve_camera(&settings)) return 2;
SpacetimeSource spacetime = {0}; SpacetimeSource spacetime = {0};
ObserverState observer; ObserverState observer;
+118 -10
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Exercise camera defaults/errors and single-frame/movie agreement (CPU PNG builds).""" """Exercise camera defaults/errors and single-frame/movie agreement (CPU builds)."""
import os import os
from pathlib import Path from pathlib import Path
import struct import struct
@@ -11,6 +11,8 @@ import zlib
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve() BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD
ENV = dict(os.environ, OMP_NUM_THREADS='4') ENV = dict(os.environ, OMP_NUM_THREADS='4')
FITSDIFF = Path(__file__).resolve().parent.parent / 'scripts' / 'fits_floatdiff.py'
PATH_MAX = os.pathconf('/', 'PC_PATH_MAX')
def run(binary, *args, ok=True, env=ENV): def run(binary, *args, ok=True, env=ENV):
@@ -21,7 +23,7 @@ def run(binary, *args, ok=True, env=ENV):
return result return result
def image_payload(path, dimensions=(64, 48), allow_black=False): def png_payload(path, dimensions):
data = path.read_bytes() data = path.read_bytes()
assert data[:8] == b'\x89PNG\r\n\x1a\n' assert data[:8] == b'\x89PNG\r\n\x1a\n'
offset, compressed = 8, bytearray() offset, compressed = 8, bytearray()
@@ -33,7 +35,32 @@ def image_payload(path, dimensions=(64, 48), allow_black=False):
if kind == b'IDAT': if kind == b'IDAT':
compressed.extend(payload) compressed.extend(payload)
offset += count + 12 offset += count + 12
raw = zlib.decompress(compressed) return zlib.decompress(compressed)
def ppm_payload(path, dimensions):
data = path.read_bytes()
assert data[:2] == b'P6'
fields, index = [], 2
while len(fields) < 3:
while data[index:index + 1].isspace():
index += 1
if data[index:index + 1] == b'#':
while data[index:index + 1] not in (b'\n', b''):
index += 1
continue
start = index
while not data[index:index + 1].isspace():
index += 1
fields.append(int(data[start:index]))
index += 1
assert (fields[0], fields[1]) == dimensions, (fields, dimensions)
return data[index:index + fields[0] * fields[1] * 3]
def image_payload(path, dimensions=(64, 48), allow_black=False):
data = path.read_bytes()
raw = ppm_payload(path, dimensions) if data[:2] == b'P6' else png_payload(path, dimensions)
assert raw, f'missing image data: {path}' assert raw, f'missing image data: {path}'
assert allow_black or any(raw), f'empty image: {path}' assert allow_black or any(raw), f'empty image: {path}'
return raw return raw
@@ -57,21 +84,25 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
for backend in ('minkowski', 'schwarzschild'): for backend in ('minkowski', 'schwarzschild'):
binary = BUILD / f'{backend}_sky' binary = BUILD / f'{backend}_sky'
help_text = run(binary, '--help').stdout help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
hdr_available = '--hdr-output' in help_text
for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'): for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'):
assert option in help_text assert option in help_text
assert '--observer-inward-speed' not in help_text assert '--observer-inward-speed' not in help_text
assert '_mesh.' in help_text, help_text
common = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 64, common = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 64,
'--height', 48, '--fov-deg', 80, '--exposure', 0.1, '--height', 48, '--fov-deg', 80, '--exposure', 0.1,
'--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4] '--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4]
def render(name, *options): def render(name, *options):
path = tmp / f'{backend}_{name}.png' path = tmp / f'{backend}_{name}.{ext}'
run(binary, *common, '--output', path, *options) run(binary, *common, '--output', path, *options)
return image_payload(path) return image_payload(path)
# CPU fast-mode CLI smoke test: the FFTW resolve must run and report its # CPU fast-mode CLI smoke test: the FFTW resolve must run and report its
# one-time setup line. # one-time setup line.
if backend == 'minkowski': if backend == 'minkowski':
fast_path = tmp / 'minkowski_fast.png' fast_path = tmp / f'minkowski_fast.{ext}'
fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2, fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2,
'--output', fast_path) '--output', fast_path)
assert 'Fast FFTW:' in fast.stderr, fast.stderr assert 'Fast FFTW:' in fast.stderr, fast.stderr
@@ -97,6 +128,44 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
('--look-dec-deg', -23, 90, -23)]: ('--look-dec-deg', -23, 90, -23)]:
assert render('partial', partial, value) == render( assert render('partial', partial, value) == render(
'complete', '--look-ra-deg', ra, '--look-dec-deg', dec) 'complete', '--look-ra-deg', ra, '--look-dec-deg', dec)
# --draw-mesh must leave the primary image untouched and only add a
# mesh-overlay sibling.
baseline = render('mesh_base')
mesh_output = tmp / f'{backend}_mesh_overlay.{ext}'
mesh_run = run(binary, *common, '--draw-mesh', '--output', mesh_output)
assert image_payload(mesh_output) == baseline
mesh_sibling = tmp / f'{backend}_mesh_overlay_mesh.{ext}'
assert mesh_sibling.exists(), mesh_run.stderr
assert image_payload(mesh_sibling) != baseline
assert not (tmp / f'{backend}_mesh_overlay_mesh_mesh.{ext}').exists()
# Only the final filename extension is replaced, dots in directories are
# not touched.
dotted_dir = tmp / 'dir.v1'
dotted_dir.mkdir(exist_ok=True)
dotted_output = dotted_dir / f'{backend}_image.final.{ext}'
run(binary, *common, '--draw-mesh', '--output', dotted_output)
assert dotted_output.exists()
dotted_mesh = dotted_dir / f'{backend}_image.final_mesh.{ext}'
assert dotted_mesh.exists(), dotted_output
assert not (dotted_dir / f'{backend}_image.final.{ext}_mesh.{ext}').exists()
assert image_payload(dotted_output) == baseline
# A mesh sibling that overflows PATH_MAX must fail before catalog or
# spacetime initialization.
target = PATH_MAX - 3
long_name = f'{backend}_image.{ext}'
long_path = '/' + 'd' * (target - len(long_name) - 2) + '/' + long_name
assert len(long_path) == target
assert target + len('_mesh') > PATH_MAX
too_long = run(binary, *common, '--draw-mesh', '--catalog',
tmp / 'missing_catalog.csv', '--output', long_path,
ok=False)
assert 'Mesh overlay output path is too long' in too_long.stderr, too_long.stderr
assert 'Blackbody backend' not in too_long.stderr, too_long.stderr
assert 'PSF cache ready' not in too_long.stderr
errors = [ errors = [
(['--observer-position', 1, 2], None), (['--observer-position', 1, 2], None),
(['--observer-position', 1, 2, 'nan'], None), (['--observer-position', 1, 2, 'nan'], None),
@@ -126,6 +195,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert message in result.stderr, result.stderr assert message in result.stderr, result.stderr
assert not missing_catalog.exists(), result.stderr assert not missing_catalog.exists(), result.stderr
assert 'PSF cache ready' not in result.stderr assert 'PSF cache ready' not in result.stderr
track = tmp / f'{backend}.csv' track = tmp / f'{backend}.csv'
run(TESTDIR / f'test_observer_{backend}', track) run(TESTDIR / f'test_observer_{backend}', track)
single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens' single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens'
@@ -136,8 +206,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
run(binary, *common, '--observer-track', track, '--frames-dir', tmp, run(binary, *common, '--observer-track', track, '--frames-dir', tmp,
'--frames-prefix', backend, '--duration', 0, '--fps', 1, '--frames-prefix', backend, '--duration', 0, '--fps', 1,
'--lens-map-output', movie_map) '--lens-map-output', movie_map)
movie = image_payload(tmp / f'{backend}_000000.png') movie = image_payload(tmp / f'{backend}_000000.{ext}')
assert single == movie, f'{backend}: single/movie PNG mismatch' assert single == movie, f'{backend}: single/movie image mismatch'
a, ta = map_vertices(single_map) a, ta = map_vertices(single_map)
b, tb = map_vertices(movie_map) b, tb = map_vertices(movie_map)
assert len(a) == len(b) and ta == tb assert len(a) == len(b) and ta == tb
@@ -146,8 +216,46 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert x[-1] == y[-1], 'ray classification mismatch' assert x[-1] == y[-1], 'ray classification mismatch'
max_error = max(max_error, *(abs(v - w) for v, w in zip(x[:-1], y[:-1]))) max_error = max(max_error, *(abs(v - w) for v, w in zip(x[:-1], y[:-1])))
assert max_error < 1e-9, max_error assert max_error < 1e-9, max_error
# A map import must still work without evaluating a camera/metric.
# Movie frames keep the clean primary image and gain correctly named
# mesh siblings; never "frame_000000.png_mesh.png".
movie_mesh_dir = tmp / f'{backend}_movie_mesh'
movie_mesh_dir.mkdir()
run(binary, *common, '--observer-track', track, '--frames-dir',
movie_mesh_dir, '--frames-prefix', 'frame', '--duration', 0,
'--fps', 1, '--draw-mesh')
assert image_payload(movie_mesh_dir / f'frame_000000.{ext}') == movie
assert (movie_mesh_dir / f'frame_000000_mesh.{ext}').exists()
assert not (movie_mesh_dir / f'frame_000000.{ext}_mesh.{ext}').exists()
# A map import must still work without evaluating a camera/metric, and
# must follow the same clean-main plus mesh-sibling rule.
assert single == render('import', '--lens-map-input', single_map) assert single == render('import', '--lens-map-input', single_map)
imported_mesh = tmp / f'{backend}_import_mesh.{ext}'
run(binary, *common, '--lens-map-input', single_map, '--draw-mesh',
'--output', imported_mesh)
assert image_payload(imported_mesh) == single
assert (tmp / f'{backend}_import_mesh_mesh.{ext}').exists()
if hdr_available:
# The clean HDR FITS payload and primary image are bit-identical
# with and without --draw-mesh; only the mesh sibling differs.
hdr_output = tmp / f'{backend}_hdr.{ext}'
hdr_mesh_output = tmp / f'{backend}_hdr_mesh.{ext}'
run(binary, *common, '--hdr-output', '--output', hdr_output)
run(binary, *common, '--hdr-output', '--draw-mesh', '--output',
hdr_mesh_output)
base_fits = tmp / f'{backend}_hdr_HDR.fits'
mesh_fits = tmp / f'{backend}_hdr_mesh_HDR.fits'
assert base_fits.exists() and mesh_fits.exists()
diff = subprocess.run([sys.executable, str(FITSDIFF), str(base_fits),
str(mesh_fits)], capture_output=True, text=True)
assert diff.returncode == 0, diff.stdout + diff.stderr
assert 'mismatches=0 max_abs=0 max_rel=0' in diff.stdout, diff.stdout
assert image_payload(hdr_output) == baseline
assert image_payload(hdr_mesh_output) == baseline
assert image_payload(tmp / f'{backend}_hdr_mesh_mesh.{ext}') != baseline
if backend == 'schwarzschild': if backend == 'schwarzschild':
# Two inward-looking free-fall samples at r=6.2696 and r=3.1593. # Two inward-looking free-fall samples at r=6.2696 and r=3.1593.
# At 16:9 the latter frame finishes in generation 0, while the # At 16:9 the latter frame finishes in generation 0, while the
@@ -163,7 +271,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert 'Ray trace generation 1: frame 0 added' in result.stderr assert 'Ray trace generation 1: frame 0 added' in result.stderr
assert 'Ray trace generation 0: frame 1 added' not in result.stderr assert 'Ray trace generation 0: frame 1 added' not in result.stderr
for frame in range(2): for frame in range(2):
image_payload(tmp / f'mixed_{frame:06d}.png', image_payload(tmp / f'mixed_{frame:06d}.{ext}',
dimensions=(64, 36), allow_black=True) dimensions=(64, 36), allow_black=True)
# Thread scheduling must preserve endpoints, frame/sample IDs and # Thread scheduling must preserve endpoints, frame/sample IDs and
# the resulting adaptive mesh across the entire slab sweep. # the resulting adaptive mesh across the entire slab sweep.
@@ -178,4 +286,4 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert parallel_map.read_bytes() == comparison_map.read_bytes(), \ assert parallel_map.read_bytes() == comparison_map.read_bytes(), \
f'movie lens map changed with {threads} threads' f'movie lens map changed with {threads} threads'
print('schwarzschild: movie lens map identical with 1, 4 and 16 threads', flush=True) print('schwarzschild: movie lens map identical with 1, 4 and 16 threads', flush=True)
print(f'{backend}: CLI checks passed; single/movie PNG identical, map max error {max_error:.3g}', flush=True) print(f'{backend}: CLI checks passed; single/movie image identical, map max error {max_error:.3g}', flush=True)
+6 -3
View File
@@ -355,9 +355,12 @@ count and each time slab's activation and terminal-ray summary.
Movie renders always print one summary per time slab; `--verbose` also prints Movie renders always print one summary per time slab; `--verbose` also prints
the ray counts before each slab is loaded. the ray counts before each slab is loaded.
Pass `--draw-mesh` to alpha-composite image-plane triangle edges as Pass `--draw-mesh` to also write the final image-plane triangle mesh as a
one-pixel-wide 0.5 linear-gray diagnostic lines at 0.5 opacity. The line `<output-stem>_mesh.png` sibling (`.ppm` in non-PNG builds). The main
rasterizer uses coverage-based antialiasing. tone-mapped image and any `--hdr-output` FITS file remain mesh-free. The
overlay alpha-composites 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.
## HDR output ## HDR output