Files
GR-raytracing/tests/test_camera_cli.py
T
wyj 8cf6106127 Fix: Skip empty movie refinement generations
Allow frames to finish refinement independently without aborting the movie. Report genuine refinement errors with generation and frame identifiers, and clarify the sample batch contract.

Add a two-frame Schwarzschild regression covering unequal refinement progress. Validated Release and Debug make test suites and 640x360 rendering of the final eight free-fall frames in both builds.
2026-09-06 08:10:29 -04:00

157 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Exercise camera defaults/errors and single-frame/movie agreement (CPU PNG builds)."""
import os
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import zlib
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
ENV = dict(os.environ, OMP_NUM_THREADS='4')
def run(binary, *args, ok=True):
result = subprocess.run([str(binary), *map(str, args)], env=ENV,
capture_output=True, text=True)
if (result.returncode == 0) != ok:
raise AssertionError(f'{binary.name} {args}: {result.returncode}\n{result.stderr}')
return result
def image_payload(path, dimensions=(64, 48), allow_black=False):
data = path.read_bytes()
assert data[:8] == b'\x89PNG\r\n\x1a\n'
offset, compressed = 8, bytearray()
while offset < len(data):
count, kind = struct.unpack_from('>I4s', data, offset)
payload = data[offset + 8:offset + 8 + count]
if kind == b'IHDR':
assert struct.unpack_from('>II', payload) == dimensions
if kind == b'IDAT':
compressed.extend(payload)
offset += count + 12
raw = zlib.decompress(compressed)
assert raw, f'missing image data: {path}'
assert allow_black or any(raw), f'empty image: {path}'
return raw
def map_vertices(path):
data = path.read_bytes()
assert data[:8] == b'GRLENS\x01\x00'
assert struct.unpack_from('<Q', data, 32)[0] == 1
vertices, triangles = struct.unpack_from('<QQ', data, 64)
offset = 80
values = []
for _ in range(vertices):
values.append(struct.unpack_from('<9dI', data, offset))
offset += 76
return values, data[offset:offset + triangles * 28]
with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
tmp = Path(directory)
for backend in ('minkowski', 'schwarzschild'):
binary = BUILD / f'{backend}_sky'
help_text = run(binary, '--help').stdout
for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'):
assert option in help_text
assert '--observer-inward-speed' not in 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'
run(binary, *common, '--output', path, *options)
return image_payload(path)
# Equivalent independently specified and inferred camera geometry.
inferred = render('position', '--observer-position', -30, 0, 0)
explicit = render('explicit', '--observer-position', -30, 0, 0,
'--look-ra-deg', 0, '--look-dec-deg', 0)
angled = render('angle', '--look-ra-deg', 0, '--look-dec-deg', 0)
assert inferred == explicit == angled
pole = render('pole', '--observer-position', 0, 0, 30)
assert pole == render('pole_explicit', '--observer-position', 0, 0, 30,
'--look-ra-deg', 0, '--look-dec-deg', -90)
default = render('default')
pos = (0, 0, 0) if backend == 'minkowski' else (0, 0, 30)
assert default == render('default_explicit', '--observer-position', *pos,
'--look-ra-deg', 90, '--look-dec-deg', -90)
assert render('radius', '--observer-radius', 40) == render(
'radius_explicit', '--observer-radius', 40, '--look-ra-deg', 90,
'--look-dec-deg', -90)
for partial, value, ra, dec in [('--look-ra-deg', 37, 37, -90),
('--look-dec-deg', -23, 90, -23)]:
assert render('partial', partial, value) == render(
'complete', '--look-ra-deg', ra, '--look-dec-deg', dec)
errors = [
(['--observer-position', 1, 2], None),
(['--observer-position', 1, 2, 'nan'], None),
(['--observer-velocity', 0, 0, 'inf'], None),
(['--look-ra-deg', 'nan'], None),
(['--look-dec-deg', 'inf'], None),
(['--observer-radius', 'nan'], None),
(['--observer-radius', 0], None),
(['--camera-roll-deg', 'nan'], None),
(['--observer-position', 0, 0, 0], 'Cannot infer'),
(['--observer-position', 3, 4, 5, '--observer-radius', 30], 'mutually exclusive'),
(['--observer-velocity', 10, 0, 0], 'not timelike'),
(['--observer-inward-speed', 0], None),
(['--observer-track', 'missing.csv', '--observer-velocity', 0, 0, 0], 'cannot be combined'),
(['--frames-dir', tmp, '--look-ra-deg', 0], 'cannot be combined'),
(['--lens-map-input', 'missing.grlens', '--camera-roll-deg', 0], 'cannot be combined'),
]
if backend == 'schwarzschild':
errors += [(['--observer-position', 1.5, 0, 0, '--observer-velocity', -0.5, 0, 0], 'capture cutoff'),
(['--observer-position', 1.75, 0, 0], 'not timelike')]
render('inside', '--observer-position', 1.75, 0, 0,
'--observer-velocity', -0.5, 0, 0, '--look-ra-deg', 0, '--look-dec-deg', 0)
for options, message in errors:
missing_catalog = tmp / 'should_not_be_created.csv'
result = run(binary, '--catalog', missing_catalog, *options, ok=False)
if message:
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(BUILD / f'test_observer_{backend}', track)
single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens'
single = render('moving', '--observer-position', 3, -4, 5,
'--observer-velocity', 0.2, -0.1, 0.3,
'--look-ra-deg', 37, '--look-dec-deg', -23,
'--camera-roll-deg', 19, '--lens-map-output', single_map)
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')
assert single == movie, f'{backend}: single/movie PNG mismatch'
a, ta = map_vertices(single_map)
b, tb = map_vertices(movie_map)
assert len(a) == len(b) and ta == tb
max_error = 0
for x, y in zip(a, b):
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.
assert single == render('import', '--lens-map-input', single_map)
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
# former still needs refinement. Finishing the empty batch used
# to abort the whole movie in generation 1.
mixed = Path(__file__).parent / 'fixtures/schwarzschild_mixed_refinement.csv'
result = run(binary, *common, '--height', 36, '--fov-deg', 60,
'--refine-max-level', 3, '--observer-track', mixed,
'--movie-track-samples', '--frames-dir', tmp,
'--frames-prefix', 'mixed', '--verbose')
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',
dimensions=(64, 36), allow_black=True)
print(f'{backend}: CLI checks passed; single/movie PNG identical, map max error {max_error:.3g}', flush=True)