Files
GR-raytracing/scripts/fast_mode_deposit_error.py
T
wyj 3deebfb2fa Feat: Add --fast-mode supersampled point-source accumulation
Add an optional CPU preview path that deposits each point-source image as a
supersampled delta and resolves the whole frame with one global Moffat
convolution plus an N x N box average, instead of splatting a per-event PSF.

- optics: FastPsfAccumulator builds the pixel-area-integral kernel of the
  target Moffat at the supersampled scale (width N*alpha, same beta).  The
  1/N^2 box average then reproduces the final pixel-area integral, so the
  requested FWHM and beta are preserved without renormalisation.  Deposits are
  per-cell atomic adds; resolve accumulates into the caller's HDR buffer.
- frame: fast branch in frame_splat_catalog with one shared supersampled
  buffer and a single resolve per frame; the accumulator is reused across
  movie frames and built from the map dimensions on lens-map import.
- main: --fast-mode, --fast-supersample N (1..8, default 2) and
  --fast-deposit nearest|bilinear (default nearest).  CPU-only and rejected in
  the HIP/dummy backends; --psf-min-y still applies per event while
  --max-cache-psf-flux does not.
- The deposition scheme was chosen by scripts/fast_mode_deposit_error.py:
  nearest keeps the PSF shape exactly with <= 0.5/N px position quantization;
  bilinear keeps the exact centroid but broadens FWHM and beta.  Recorded in
  benchmarks/fast_mode_deposit_2026-09-18.md.
- tests/test_frame.c covers fast nearest vs the direct evaluator at the snapped
  centre, flux conservation, bilinear centroid, min-Y discard, frame plumbing,
  and HDR accumulation onto a non-zero background.
- benchmarks/fast_mode_cpu_2026-09-18.md records a ~10x speedup on the 2MASS
  galactic-centre field with small tone-mapped differences.
2026-09-24 01:36:25 -04:00

350 lines
14 KiB
Python

#!/usr/bin/env python3
"""Fast-mode point-source deposition error experiment.
Question this script answers: when a single star is deposited into a
supersampled buffer at its exact continuous position, does a single nearest
supersampled pixel lose enough HDR information that we must instead deposit
into the 4 adjacent pixels (bilinear)?
Model (must match the intended C implementation):
* Target PSF is the normalized circular Moffat
M(r; a, b) = (b-1)/(pi a^2) * (1 + r^2/a^2)^(-b)
with a = FWHM / (2 sqrt(2^(1/b) - 1)).
* Reference image = pixel-area integral of M at the true star position
(8-point Gauss-Legendre quadrature, as used by the direct evaluator).
* Fast image with supersample factor N:
- deposit the event delta into the N x N supersampled grid
(nearest = 1 cell, bilinear = 4 adjacent cells);
- convolve the whole ss buffer with the single global kernel
k[m] = integral over ss cell m of M(z; N*a, b) (4-point quadrature);
- downsample by averaging each N x N block (1/N^2), which together with
the unnormalised N-scaled kernel reproduces the final pixel integral.
Usage: python3 scripts/fast_mode_deposit_error.py [--phases K] [--grid W]
[--tail R] [--max-radius R]
"""
import argparse
from math import pi, sqrt, ceil, log
import numpy as np
GAUSS4_X = np.array([-0.8611363115940526, -0.3399810435848563,
0.3399810435848563, 0.8611363115940526])
GAUSS4_W = np.array([0.3478548451374539, 0.6521451548625461,
0.6521451548625461, 0.3478548451374539])
GAUSS8_X = np.array([-0.9602898564975363, -0.7966664774136267,
-0.5255324099163290, -0.1834346424956498,
0.1834346424956498, 0.5255324099163290,
0.7966664774136267, 0.9602898564975363])
GAUSS8_W = np.array([0.1012285362903763, 0.2223810344533745,
0.3137066458778873, 0.3626837833783620,
0.3626837833783620, 0.3137066458778873,
0.2223810344533745, 0.1012285362903763])
TAIL_ABS_HDR = 1e-6
BOUNDARY_HDR = 1e-7
def moffat_alpha(fwhm, beta):
return fwhm / (2.0 * sqrt(2.0 ** (1.0 / beta) - 1.0))
def support_radius(alpha, beta, scaled_flux=1.0, rel_tail=1e-8):
norm = scaled_flux * (beta - 1.0) / (pi * alpha * alpha)
rt = min(rel_tail, TAIL_ABS_HDR / scaled_flux)
tail = alpha * sqrt(rt ** (1.0 / (1.0 - beta)) - 1.0)
br = BOUNDARY_HDR / norm
bound = 0.0 if br >= 1.0 else alpha * sqrt(br ** (-1.0 / beta) - 1.0)
return max(tail, bound)
def pixel_integral_image(alpha, beta, width, height, xs, ys, nodes, weights):
"""Reference: Moffat pixel-area integral on a final-resolution grid."""
img = np.zeros((height, width), dtype=np.float64)
norm = (beta - 1.0) / (pi * alpha * alpha)
x = np.arange(width)[None, :]
y = np.arange(height)[:, None]
inv = 1.0 / (alpha * alpha)
for iy in range(len(nodes)):
sy = y + 0.5 + 0.5 * nodes[iy] - ys
for ix in range(len(nodes)):
sx = x + 0.5 + 0.5 * nodes[ix] - xs
img += (0.25 * weights[ix] * weights[iy] * norm *
(1.0 + (sx * sx + sy * sy) * inv) ** (-beta))
return img
def build_kernel(alpha, n, beta, radius, nodes, weights):
"""Global ss kernel indexed by offset m in [-radius, radius].
k[m] = integral over ss cell m of I(z/N; alpha, beta), where I is the
final-unit normalized Moffat. Its total weight is ~N^2, so averaging each
N x N downsampled block reproduces the final pixel-area integral exactly.
"""
offsets = np.arange(-radius, radius + 1)
k = np.zeros((2 * radius + 1, 2 * radius + 1), dtype=np.float64)
alpha_ss = n * alpha
norm = (beta - 1.0) / (pi * alpha * alpha)
inv = 1.0 / (alpha_ss * alpha_ss)
mx = offsets[None, :]
my = offsets[:, None]
for iy in range(len(nodes)):
sy = my + 0.5 * nodes[iy]
for ix in range(len(nodes)):
sx = mx + 0.5 * nodes[ix]
k += (0.25 * weights[ix] * weights[iy] * norm *
(1.0 + (sx * sx + sy * sy) * inv) ** (-beta))
return k
def place_kernel(out, kernel, radius, u0x, u0y, scale):
"""out[p] += scale * kernel[p - u0]; clip to the buffer."""
h, w = out.shape
x0, x1 = u0x - radius, u0x + radius
y0, y1 = u0y - radius, u0y + radius
kx0 = max(0, -x0)
ky0 = max(0, -y0)
kx1 = kernel.shape[1] - max(0, x1 - (w - 1))
ky1 = kernel.shape[0] - max(0, y1 - (h - 1))
ix0 = max(0, x0)
iy0 = max(0, y0)
out[iy0:iy0 + (ky1 - ky0), ix0:ix0 + (kx1 - kx0)] += \
scale * kernel[ky0:ky1, kx0:kx1]
def fast_image(width, height, n, xs, ys, kernel, radius, deposit):
ss_w, ss_h = n * width, n * height
out = np.zeros((ss_h, ss_w), dtype=np.float64)
zx, zy = n * xs, n * ys
ix = int(np.floor(zx))
iy = int(np.floor(zy))
if deposit == "nearest":
place_kernel(out, kernel, radius, ix, iy, 1.0)
else:
# Interpolate between the two ss cell centres bracketing the star.
bx = int(np.floor(zx - 0.5))
by = int(np.floor(zy - 0.5))
fx, fy = zx - 0.5 - bx, zy - 0.5 - by
for dy, wy in ((0, 1 - fy), (1, fy)):
for dx, wx in ((0, 1 - fx), (1, fx)):
if wx * wy != 0.0:
place_kernel(out, kernel, radius, bx + dx, by + dy,
wx * wy)
final = out.reshape(height, n, width, n).mean(axis=(1, 3))
return final
def bilinear_sample(img, x, y):
h, w = img.shape
x = min(max(x, 0.0), w - 1.0)
y = min(max(y, 0.0), h - 1.0)
x0, y0 = int(np.floor(x)), int(np.floor(y))
x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)
tx, ty = x - x0, y - y0
return ((1 - ty) * ((1 - tx) * img[y0, x0] + tx * img[y0, x1]) +
ty * ((1 - tx) * img[y1, x0] + tx * img[y1, x1]))
def image_centroid(img):
h, w = img.shape
xs = np.arange(w)[None, :] + 0.5
ys = np.arange(h)[:, None] + 0.5
total = img.sum()
if total <= 0.0:
return 0.5 * w, 0.5 * h
return float((img * xs).sum() / total), float((img * ys).sum() / total)
def radial_fwhm(img, center, step=0.02, r_max=None):
h, w = img.shape
cx, cy = center
if r_max is None:
r_max = 0.5 * min(w, h) - 1.0
peak = bilinear_sample(img, cx, cy)
if peak <= 0.0:
return float("nan"), float("nan")
radii = np.arange(0.0, r_max, step)
prof = np.array([bilinear_sample(img, cx + r, cy) for r in radii])
half = 0.5 * peak
idx = np.where(prof <= half)[0]
if len(idx) == 0:
return float("nan"), peak
i = idx[0]
if i == 0:
return float("nan"), peak
r0, r1 = radii[i - 1], radii[i]
v0, v1 = prof[i - 1], prof[i]
r_half = r0 + (half - v0) * (r1 - r0) / (v1 - v0)
return 2.0 * r_half, peak
def moffat_profile(r, amp, alpha, beta):
return amp * (1.0 + (r / alpha) ** 2) ** (-beta)
def fit_beta(img, center, alpha_hint):
from scipy.optimize import curve_fit
h, w = img.shape
cx, cy = center
r_max = 0.4 * min(w, h)
step = 0.05
radii = np.arange(1.5, r_max, step)
prof = np.array([bilinear_sample(img, cx + r, cy) for r in radii])
peak = bilinear_sample(img, cx, cy)
good = prof > 1e-6 * peak
if good.sum() < 8:
return float("nan")
try:
popt, _ = curve_fit(moffat_profile, radii[good], prof[good],
p0=[peak, alpha_hint, 4.0],
bounds=([0.0, 0.2 * alpha_hint, 1.05],
[np.inf, 5.0 * alpha_hint, 20.0]),
maxfev=20000)
return float(popt[2])
except Exception:
return float("nan")
def phase_errors(ref, fast, peak):
diff = fast - ref
return (float(np.abs(diff).max() / peak),
float(np.sqrt(np.mean(diff * diff)) / peak))
def run_config(fwhm, beta, n, phases, grid, nodes_k, weights_k, tail,
max_radius, deposit):
alpha = moffat_alpha(fwhm, beta)
alpha_ss = n * alpha
r_full = support_radius(alpha_ss, beta, 1.0, tail)
radius = int(min(ceil(r_full) + 1, max_radius))
kernel = build_kernel(alpha, n, beta, radius, GAUSS4_X, GAUSS4_W)
retained = float(kernel.sum() / (n * n))
dxs = (np.arange(phases) + 0.5) / phases
max_err = rms_err = 0.0
central_err = 0.0
max_centroid = 0.0
fwhm_err = 0.0
beta_bias = None
ref_fwhm = 0.0
for dx in dxs:
for dy in dxs:
xs = grid / 2 + 0.5 + dx
ys = grid / 2 + 0.5 + dy
ref = pixel_integral_image(alpha, beta, grid, grid, xs, ys,
GAUSS8_X, GAUSS8_W)
peak = ref.max()
fast = fast_image(grid, grid, n, xs, ys, kernel, radius, deposit)
e_max, e_rms = phase_errors(ref, fast, peak)
max_err = max(max_err, e_max)
rms_err = max(rms_err, e_rms)
# HDR error of the star's own core pixels (central 3x3).
ci = int(np.clip(grid / 2, 0, grid - 3))
central_err = max(central_err,
float(np.abs(fast[ci:ci + 3, ci:ci + 3] -
ref[ci:ci + 3, ci:ci + 3]).max() /
peak))
cref = image_centroid(ref)
cfast = image_centroid(fast)
max_centroid = max(max_centroid,
sqrt((cref[0] - cfast[0]) ** 2 +
(cref[1] - cfast[1]) ** 2))
# Shape error is isolated by comparing both profiles about the same
# centre: the fast image's own centroid. Bilinear already keeps
# that centre at the true position, so its reference is ref.
if deposit == "bilinear":
ref_same = ref
else:
ref_same = pixel_integral_image(alpha, beta, grid, grid,
cfast[0], cfast[1],
GAUSS8_X, GAUSS8_W)
f_ref, _ = radial_fwhm(ref_same, cfast)
f_fast, _ = radial_fwhm(fast, cfast)
if np.isfinite(f_ref) and np.isfinite(f_fast):
ref_fwhm = f_ref
fwhm_err = max(fwhm_err, abs(f_fast - f_ref) / f_ref)
if fwhm >= 2.0:
# fit_beta() has its own pixel-integration bias; compare the
# fast fit with the reference fit computed the same way so
# beterr isolates the deposition-induced shape change.
b_ref = fit_beta(ref_same, cfast, alpha)
b_fast = fit_beta(fast, cfast, alpha)
if np.isfinite(b_ref) and np.isfinite(b_fast) and b_ref > 0.0:
error = abs(b_fast - b_ref) / b_ref
beta_bias = error if beta_bias is None else max(beta_bias, error)
return {
"fwhm": fwhm, "beta": beta, "n": n, "deposit": deposit,
"alpha": alpha, "radius": radius, "retained": retained,
"max_err": max_err, "rms_err": rms_err, "central_err": central_err,
"centroid": max_centroid, "ref_fwhm": ref_fwhm,
"fwhm_err": fwhm_err,
"beta_bias": float("nan") if beta_bias is None else beta_bias,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--phases", type=int, default=24)
ap.add_argument("--grid", type=int, default=48)
ap.add_argument("--tail", type=float, default=1e-6)
ap.add_argument("--max-radius", type=int, default=64)
ap.add_argument("--deposits", default="nearest,bilinear")
args = ap.parse_args()
configs = [(1.0, 8.0), (2.7, 8.0), (6.0, 8.0),
(1.0, 4.5), (2.7, 4.5), (6.0, 4.5),
(1.0, 3.5), (2.7, 3.5)]
ns = [2, 3, 4]
deposits = [d for d in args.deposits.split(",") if d]
print("Fast-mode deposition error experiment")
print(f"grid={args.grid} phases={args.phases} tail={args.tail:g} "
f"max_radius={args.max_radius}")
print("kernel quadrature = 4-point Gauss; reference = 8-point Gauss")
print()
header = (f"{'FWHM':>5} {'beta':>5} {'N':>2} {'deposit':>9} {'R_ss':>5} "
f"{'retain':>8} {'maxErr':>9} {'rmsErr':>9} {'coreErr':>9} "
f"{'centErr':>9} {'FWHMerr':>9} {'beterr':>9}")
print(header)
print("-" * len(header))
rows = []
for fwhm, beta in configs:
for n in ns:
for deposit in deposits:
r = run_config(fwhm, beta, n, args.phases, args.grid,
GAUSS4_X, GAUSS4_W, args.tail, args.max_radius,
deposit)
rows.append(r)
print(f"{fwhm:5.2f} {beta:5.2f} {n:2d} {deposit:>9} "
f"{r['radius']:5d} {r['retained']:8.5f} "
f"{r['max_err']:9.3e} {r['rms_err']:9.3e} "
f"{r['central_err']:9.3e} {r['centroid']:9.3e} "
f"{r['fwhm_err']:9.3e} {r['beta_bias']:9.3e}")
print()
print("Worst case over all tested configurations, by deposit scheme:")
for deposit in deposits:
sel = [r for r in rows if r["deposit"] == deposit]
if not sel:
continue
print(f" {deposit:>9}: maxErr={max(r['max_err'] for r in sel):.3e} "
f"rmsErr={max(r['rms_err'] for r in sel):.3e} "
f"coreErr={max(r['central_err'] for r in sel):.3e} "
f"centErr={max(r['centroid'] for r in sel):.3e} "
f"FWHMerr={max(r['fwhm_err'] for r in sel):.3e} "
f"beterr={np.nanmax([r['beta_bias'] for r in sel]):.3e}")
print()
print("FWHMerr compares the fast image with a direct image at the same "
"centroid, so it is a pure shape error (0 for exact position "
"deposition at its snapped centre). centErr is the position "
"quantization vs the true continuous star position. beterr is the "
"deposition-induced Moffat beta change, isolated from the radial "
"fit's own pixel-integration bias by comparing with a reference fit; "
"reported only for FWHM >= 2 px (nan otherwise).")
if __name__ == "__main__":
main()