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

+116 -8
View File
@@ -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.