From 2a0197229a389009c43e7b2e7ab183981f3d1282 Mon Sep 17 00:00:00 2001 From: Yingjie Wang Date: Sat, 26 Sep 2026 17:01:25 -0400 Subject: [PATCH] Feat: Write mesh overlay as a separate _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 _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. --- README.md | 12 +- README.zh-CN.md | 6 +- ...d.png => schwarzschild_test_grid_mesh.png} | Bin mk/reference_images.mk | 4 +- src/main.c | 180 +++++++++++++----- tests/test_camera_cli.py | 124 +++++++++++- usage.md | 9 +- 7 files changed, 267 insertions(+), 68 deletions(-) rename assets/images/{schwarzschild_test_grid.png => schwarzschild_test_grid_mesh.png} (100%) diff --git a/README.md b/README.md index 234c66d..d4fd931 100644 --- a/README.md +++ b/README.md @@ -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 This example uses `assets/sky_grid_5deg.csv` to inspect lensing and adaptive -mesh refinement in Schwarzschild spacetime. `--draw-mesh` overlays the final -image-plane triangles. +mesh refinement in Schwarzschild spacetime. The main output is the clean +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 mkdir -p output/imgs @@ -190,9 +193,10 @@ mkdir -p output/imgs --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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 3acf7f4..74b0154 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 mkdir -p output/imgs @@ -131,9 +131,9 @@ mkdir -p output/imgs --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 自由落体相机轨迹 diff --git a/assets/images/schwarzschild_test_grid.png b/assets/images/schwarzschild_test_grid_mesh.png similarity index 100% rename from assets/images/schwarzschild_test_grid.png rename to assets/images/schwarzschild_test_grid_mesh.png diff --git a/mk/reference_images.mk b/mk/reference_images.mk index 40f26d3..fa100ec 100644 --- a/mk/reference_images.mk +++ b/mk/reference_images.mk @@ -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 $(MAKE) SPACETIME=minkowski ENABLE_HDR=1 backend 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 $(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 diff --git a/src/main.c b/src/main.c index 3571aa3..411876b 100644 --- a/src/main.c +++ b/src/main.c @@ -159,18 +159,24 @@ static int validate_tonemapped_output_path(const char *path) { 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 static int make_hdr_output_path(char path[PATH_MAX], const char *output_path) { static const char hdr_suffix[] = "_HDR.fits"; - const char *filename = strrchr(output_path, '/'); - const char *extension; - size_t stem_length; + const size_t stem_length = final_stem_length(output_path); - 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) return -1; 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 +/* 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, const char **write_path) { #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-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-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n" - " --draw-mesh Draw the final lens mesh overlay (default: disabled)\n" + " --refine-min-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n", + stdout); +#ifdef ENABLE_PNG + fputs(" --draw-mesh Also write the final lens-mesh overlay as _mesh.png\n", + stdout); +#else + fputs(" --draw-mesh Also write the final lens-mesh overlay as _mesh.ppm\n", + stdout); +#endif + fputs( "\nMovie and observer track:\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" @@ -722,22 +810,15 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog, free(hdr); return -1; } - if (s->draw_mesh) - frame_draw_mesh(&mesh, hdr, s->width, s->height, 0.5, 0.5); -#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); + FrameOutputPaths output_paths; + if (build_frame_output_paths(s, output_path, &output_paths)) { 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"); + const int result = write_frame_outputs( + s, &mesh, hdr, s->width, s->height, s->horizontal_fov_deg, &output_paths, + images, catalog->count, ""); report_psf_splat(s, &psf_stats); frame_lens_mesh_destroy(&mesh); 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) { 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); - if (hdr == NULL || frame_output_path(output_path, s, movie.frames[i].frame_id)) { - free(hdr); + if (hdr == NULL) goto done; - } CatalogPrefetchStats prefetch = {0}; PsfSplatStats psf_stats = {0}; RenderProgress progress = { @@ -925,12 +1008,10 @@ static int render_movie(const Settings *s, StarCatalog *catalog, free(hdr); goto done; } - 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); + const int write_result = write_frame_outputs( + s, &movie.frames[i].mesh, hdr, s->width, s->height, + s->horizontal_fov_deg, &output_paths, images, catalog->count, ""); 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); if (write_result) 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) { const char *output_path = s->output_path; char movie_path[PATH_MAX]; +#ifndef PSF_BACKEND_DUMMY + FrameOutputPaths output_paths; +#endif if (map.frame_count > 1) { 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); @@ -989,7 +1073,11 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) { } 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; #endif #ifdef PSF_BACKEND_DUMMY @@ -1020,28 +1108,17 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) { result = -1; 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 -#ifdef ENABLE_HDR_OUTPUT - if (s->write_hdr_output && - write_hdr_fits(s->hdr_output_path, hdr, map.width, map.height, - map.horizontal_fov_deg)) { - 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); + const int write_result = write_frame_outputs( + s, &map.frames[i].mesh, hdr, map.width, map.height, + map.horizontal_fov_deg, &output_paths, images, catalog->count, + "; imported lens map"); 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); if (write_result) { result = -1; break; } #else + if (s->draw_mesh) + fputs("Dummy PSF backend ignores --draw-mesh.\n", stderr); free(hdr); fprintf(stderr, "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; } #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; SpacetimeSource spacetime = {0}; ObserverState observer; diff --git a/tests/test_camera_cli.py b/tests/test_camera_cli.py index 30931c3..51f477c 100644 --- a/tests/test_camera_cli.py +++ b/tests/test_camera_cli.py @@ -1,5 +1,5 @@ #!/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 from pathlib import Path import struct @@ -11,6 +11,8 @@ import zlib 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 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): @@ -21,7 +23,7 @@ def run(binary, *args, ok=True, env=ENV): return result -def image_payload(path, dimensions=(64, 48), allow_black=False): +def png_payload(path, dimensions): data = path.read_bytes() assert data[:8] == b'\x89PNG\r\n\x1a\n' offset, compressed = 8, bytearray() @@ -33,7 +35,32 @@ def image_payload(path, dimensions=(64, 48), allow_black=False): if kind == b'IDAT': compressed.extend(payload) 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 allow_black or any(raw), f'empty image: {path}' return raw @@ -57,21 +84,25 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory: for backend in ('minkowski', 'schwarzschild'): binary = BUILD / f'{backend}_sky' 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'): assert option 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, '--height', 48, '--fov-deg', 80, '--exposure', 0.1, '--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4] + def render(name, *options): - path = tmp / f'{backend}_{name}.png' + path = tmp / f'{backend}_{name}.{ext}' run(binary, *common, '--output', path, *options) return image_payload(path) # CPU fast-mode CLI smoke test: the FFTW resolve must run and report its # one-time setup line. 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, '--output', fast_path) 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)]: assert render('partial', partial, value) == render( '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 = [ (['--observer-position', 1, 2], 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 not missing_catalog.exists(), result.stderr assert 'PSF cache ready' not in result.stderr + track = tmp / f'{backend}.csv' run(TESTDIR / f'test_observer_{backend}', track) single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens' @@ -136,7 +206,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory: run(binary, *common, '--observer-track', track, '--frames-dir', tmp, '--frames-prefix', backend, '--duration', 0, '--fps', 1, '--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' a, ta = map_vertices(single_map) b, tb = map_vertices(movie_map) @@ -146,8 +216,46 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory: 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]))) 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) + 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': # 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 @@ -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 0: frame 1 added' not in result.stderr 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) # Thread scheduling must preserve endpoints, frame/sample IDs and # the resulting adaptive mesh across the entire slab sweep. diff --git a/usage.md b/usage.md index ef6c597..2eaded1 100644 --- a/usage.md +++ b/usage.md @@ -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 the ray counts before each slab is loaded. -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. +Pass `--draw-mesh` to also write the final image-plane triangle mesh as a +`_mesh.png` sibling (`.ppm` in non-PNG builds). The main +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