Feat: support general single-frame cameras and add a near-horizon example
This commit is contained in:
1 parent
94149d75e4
commit
6ae223a642
15 files changed
+771
-166
No files matched your search
@@ -0,0 +1,140 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,157 @@
|
||||
#include "geodesic.h"
|
||||
#include "observer_track.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define CHECK(condition) do { if (!(condition)) { \
|
||||
fprintf(stderr, "observer regression failed at line %d: %s\n", __LINE__, #condition); \
|
||||
return 1; } } while (0)
|
||||
|
||||
/* Independent covariant four-metric contraction in extended precision. */
|
||||
static long double dot(const MetricData *m, const double a[4], const double b[4]) {
|
||||
long double g[4][4] = {{0}};
|
||||
g[0][0] = -(long double)m->alpha * m->alpha;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
g[i + 1][j + 1] = m->gamma[i][j];
|
||||
g[0][0] += (long double)m->gamma[i][j] * m->beta[i] * m->beta[j];
|
||||
g[0][i + 1] += (long double)m->gamma[i][j] * m->beta[j];
|
||||
g[i + 1][0] = g[0][i + 1];
|
||||
}
|
||||
long double result = 0;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
for (int j = 0; j < 4; ++j) result += g[i][j] * a[i] * b[j];
|
||||
return result;
|
||||
}
|
||||
|
||||
static int check_state(const MetricData *m, const ObserverCamera *c,
|
||||
const ObserverState *o) {
|
||||
CHECK(o->coordinate_time == c->coordinate_time);
|
||||
CHECK(o->tetrad[0][0] > 0);
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
CHECK(o->coordinate_position[i] == c->position[i]);
|
||||
CHECK(fabs(o->tetrad[0][i + 1] / o->tetrad[0][0] - c->velocity[i]) < 1e-12);
|
||||
}
|
||||
for (int a = 0; a < 4; ++a)
|
||||
for (int b = 0; b < 4; ++b)
|
||||
CHECK(fabsl(dot(m, o->tetrad[a], o->tetrad[b]) -
|
||||
(a == b ? (a == 0 ? -1 : 1) : 0)) < 1e-11L);
|
||||
const double n[3] = {0.36, 0.48, 0.8};
|
||||
double k[4];
|
||||
for (int mu = 0; mu < 4; ++mu) {
|
||||
k[mu] = o->tetrad[0][mu];
|
||||
for (int a = 0; a < 3; ++a) k[mu] -= n[a] * o->tetrad[a + 1][mu];
|
||||
}
|
||||
CHECK(k[0] > 0 && fabsl(dot(m, k, k)) < 1e-11L);
|
||||
CHECK(fabsl(dot(m, k, o->tetrad[0]) + 1) < 1e-11L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
SpacetimeSource source = {0};
|
||||
CHECK(spacetime_create_default(&source) == 0);
|
||||
ObserverCamera camera = {.position = {3, -4, 5}, .velocity = {0.2, -0.1, 0.3},
|
||||
.look_ra_deg = 37, .look_dec_deg = -23, .roll_deg = 19};
|
||||
MetricData metric;
|
||||
ObserverState state;
|
||||
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
CHECK(check_state(&metric, &camera, &state) == 0);
|
||||
if (argc == 2) {
|
||||
ObserverSample sample = {.coordinate_time = 0, .proper_time = 0};
|
||||
memcpy(sample.coordinate_position, state.coordinate_position, sizeof sample.coordinate_position);
|
||||
memcpy(sample.tetrad, state.tetrad, sizeof sample.tetrad);
|
||||
ObserverTrack track = {.samples = &sample, .count = 1};
|
||||
CHECK(observer_track_write_csv(&track, argv[1]) == 0);
|
||||
}
|
||||
/* A rescaled/shifted coordinate system can have timelike |dx/dt| > 1.
|
||||
* The builder must use the metric, not impose a Euclidean speed limit. */
|
||||
const MetricData shifted_metric = {.alpha = 2, .beta = {0.1, -0.2, 0.3},
|
||||
.gamma = {{1, 0.1, 0}, {0.1, 1.2, 0.1}, {0, 0.1, 0.9}}};
|
||||
const ObserverCamera fast_coordinate = {.velocity = {1.2, 0, 0},
|
||||
.look_ra_deg = 123, .look_dec_deg = 45, .roll_deg = -31};
|
||||
CHECK(observer_from_coordinate_camera(&shifted_metric, &fast_coordinate,
|
||||
&state, NULL) == OBSERVER_BUILD_OK);
|
||||
CHECK(check_state(&shifted_metric, &fast_coordinate, &state) == 0);
|
||||
MetricData rounded_metric = shifted_metric;
|
||||
rounded_metric.gamma[1][0] = nextafter(rounded_metric.gamma[1][0], INFINITY);
|
||||
CHECK(observer_from_coordinate_camera(&rounded_metric, &fast_coordinate,
|
||||
&state, NULL) == OBSERVER_BUILD_OK);
|
||||
CHECK(check_state(&rounded_metric, &fast_coordinate, &state) == 0);
|
||||
MetricData invalid_metric = shifted_metric;
|
||||
invalid_metric.gamma[2][2] = -1;
|
||||
CHECK(observer_from_coordinate_camera(&invalid_metric, &fast_coordinate,
|
||||
&state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
|
||||
const double ras[] = {0, 37, 90, 180, 359.9};
|
||||
const double decs[] = {-90, -23, 0, 45, 90};
|
||||
for (int a = 0; a < 5; ++a)
|
||||
for (int b = 0; b < 5; ++b) {
|
||||
camera.look_ra_deg = ras[a]; camera.look_dec_deg = decs[b];
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
CHECK(check_state(&metric, &camera, &state) == 0);
|
||||
}
|
||||
camera.look_ra_deg = 0; camera.look_dec_deg = 0; camera.roll_deg = 0;
|
||||
ObserverState unrolled;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &unrolled, NULL) == OBSERVER_BUILD_OK);
|
||||
camera.roll_deg = 90;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
for (int mu = 0; mu < 4; ++mu) {
|
||||
CHECK(fabs(state.tetrad[2][mu] - unrolled.tetrad[3][mu]) < 1e-12);
|
||||
CHECK(fabs(state.tetrad[3][mu] + unrolled.tetrad[2][mu]) < 1e-12);
|
||||
}
|
||||
camera.velocity[0] = NAN;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
|
||||
camera.velocity[0] = 10;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
|
||||
camera.velocity[0] = 0;
|
||||
camera.roll_deg = INFINITY;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
|
||||
|
||||
#ifdef SPACETIME_SCHWARZSCHILD
|
||||
/* Ingoing radial light seen from the horizon and its interior must still
|
||||
* trace backwards to the external sky, rather than be classified captured. */
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.05,
|
||||
.max_steps = 8192, .capture_log_alpha_p0 = 8};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
camera = (ObserverCamera){.position = {2.25 - 0.25 * i, 0, 0},
|
||||
.velocity = {-0.5, 0, 0}};
|
||||
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
CHECK(check_state(&metric, &camera, &state) == 0);
|
||||
const RayEndpoint ray = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
|
||||
CHECK(ray.status == RAY_ENDPOINT_ESCAPED);
|
||||
CHECK(fabs(ray.n_infinity[0] - 1) < 1e-12);
|
||||
/* Radial ingoing KS photon has k^r=-k^t and conserved E=k^t.
|
||||
* Current escape convention measures Eulerian energy at finite R=256. */
|
||||
const double energy = state.tetrad[0][0] - state.tetrad[1][0];
|
||||
CHECK(fabs(ray.frequency_ratio - sqrt(1 + 2.0 / 256) / energy) < 2e-6);
|
||||
memset(camera.velocity, 0, sizeof camera.velocity);
|
||||
if (i > 0)
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
|
||||
}
|
||||
#else
|
||||
/* For transverse velocity along Y, projected +X remains F=(0,1,0,0).
|
||||
* Independently, k=(gamma,-1,gamma*v,0) gives aberration and Doppler. */
|
||||
camera = (ObserverCamera){.velocity = {0, 0.6, 0}};
|
||||
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 1, .max_steps = 2048};
|
||||
const RayEndpoint ray = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
|
||||
CHECK(ray.status == RAY_ENDPOINT_ESCAPED);
|
||||
CHECK(fabs(ray.n_infinity[0] - 0.8) < 1e-12);
|
||||
CHECK(fabs(ray.n_infinity[1] + 0.6) < 1e-12);
|
||||
CHECK(fabs(ray.frequency_ratio - 0.8) < 1e-12);
|
||||
camera.position[0] = 25; camera.position[1] = -30; camera.position[2] = 10;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
|
||||
const RayEndpoint shifted = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
|
||||
CHECK(shifted.status == ray.status && fabs(shifted.frequency_ratio - ray.frequency_ratio) < 1e-12);
|
||||
for (int i = 0; i < 3; ++i) CHECK(fabs(shifted.n_infinity[i] - ray.n_infinity[i]) < 1e-12);
|
||||
camera.velocity[1] = 1;
|
||||
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
|
||||
#endif
|
||||
spacetime_destroy(&source);
|
||||
puts("coordinate-camera regression passed");
|
||||
return 0;
|
||||
}
|
||||
+14
-16
@@ -4,12 +4,22 @@
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static int camera_at(const SpacetimeSource *source, double radius,
|
||||
double ra, double dec, ObserverState *out) {
|
||||
ObserverCamera camera = {.look_ra_deg = ra, .look_dec_deg = dec};
|
||||
const ObserverState pointing = observer_fixed_at_origin_look_at(ra, dec);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
camera.position[i] = -radius * pointing.tetrad[1][i + 1];
|
||||
MetricData metric;
|
||||
return spacetime_eval(source, 0.0, camera.position, &metric) ||
|
||||
observer_from_coordinate_camera(&metric, &camera, out, NULL);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
SpacetimeSource spacetime = {0};
|
||||
MetricData metric;
|
||||
ObserverState observer;
|
||||
ObserverState oriented_observer;
|
||||
ObserverState inward_observer;
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.1,
|
||||
.max_steps = 4096,
|
||||
.capture_log_alpha_p0 = 8.0};
|
||||
@@ -18,8 +28,8 @@ int main(void) {
|
||||
spacetime_eval(&spacetime, 0.0, (double[]){2.0, 0.0, 0.0}, &metric) ||
|
||||
!isfinite(metric.alpha) || !isfinite(metric.gamma[0][0]) ||
|
||||
!isfinite(metric.K[0][0]) ||
|
||||
observer_static_schwarzschild_ks(1.0, 30.0, &observer) ||
|
||||
observer_static_schwarzschild_ks_look_at(1.0, 40.0, 270.0, 30.0,
|
||||
camera_at(&spacetime, 30.0, 180.0, 0.0, &observer) ||
|
||||
camera_at(&spacetime, 40.0, 270.0, 30.0,
|
||||
&oriented_observer) ||
|
||||
fabs(oriented_observer.coordinate_position[0]) > 1e-12 ||
|
||||
fabs(oriented_observer.coordinate_position[1] - 20.0 * sqrt(3.0)) >
|
||||
@@ -30,19 +40,7 @@ int main(void) {
|
||||
fabs(oriented_observer.tetrad[1][2] + sqrt(0.95) * sqrt(3.0) / 2.0) >
|
||||
1e-12 ||
|
||||
fabs(oriented_observer.tetrad[1][3] - 0.5 * sqrt(0.95)) >
|
||||
1e-12 ||
|
||||
observer_inward_schwarzschild_ks(1.0, 30.0, 0.5,
|
||||
&inward_observer) ||
|
||||
fabs(inward_observer.tetrad[0][0] -
|
||||
(2.0 / sqrt(3.0)) * (observer.tetrad[0][0] +
|
||||
0.5 * observer.tetrad[1][0])) >
|
||||
1e-12 ||
|
||||
fabs(inward_observer.tetrad[1][1] -
|
||||
(2.0 / sqrt(3.0)) * (0.5 * observer.tetrad[0][1] +
|
||||
observer.tetrad[1][1])) >
|
||||
1e-12 ||
|
||||
!observer_inward_schwarzschild_ks(1.0, 30.0, 1.0,
|
||||
&inward_observer))
|
||||
1e-12)
|
||||
goto done;
|
||||
const RayEndpoint central = geodesic_trace_past(
|
||||
&spacetime, &observer, (double[]){1.0, 0.0, 0.0}, &trace);
|
||||
|
||||
Reference in new issue
Block a user