Files
GR-raytracing/tests/test_camera_cli.py
T

141 lines
6.9 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):
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) == (64, 48)
if kind == b'IDAT':
compressed.extend(payload)
offset += count + 12
raw = zlib.decompress(compressed)
assert 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)
print(f'{backend}: CLI checks passed; single/movie PNG identical, map max error {max_error:.3g}', flush=True)