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:
wyj committed 2026-09-26 17:01:25 -04:00
1 parent e4d093a9fe
commit 2a0197229a
7 files changed
+267 -68

No files matched your search

+132 -48
View File
@@ -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;