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.
This commit is contained in:
1 parent
e4d093a9fe
commit
2a0197229a
7 files changed
+267
-68
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
[](assets/images/schwarzschild_test_grid.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
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
```
|
||||
|
||||
[](assets/images/schwarzschild_test_grid.png)
|
||||
[](assets/images/schwarzschild_test_grid_mesh.png)
|
||||
|
||||
*4K 测试网格参考图像。点击查看完整分辨率。*
|
||||
*4K 测试网格参考图像,展示 `_mesh.png` 网格叠加结果。点击查看完整分辨率。*
|
||||
|
||||
### Schwarzschild 自由落体相机轨迹
|
||||
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
+132
-48
@@ -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 <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"
|
||||
" --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;
|
||||
|
||||
+116
-8
@@ -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.
|
||||
|
||||
@@ -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
|
||||
`<output-stem>_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
|
||||
|
||||
|
||||
Reference in new issue
Block a user