Files
GR-raytracing/scripts/fast_mode_error_decomposition.py
wyj e4d093a9fe Doc: Record 4K fast-mode accuracy limits and error distribution
Add the production-density 4K Galactic-center comparison to the 2026-09-25 benchmark record, including the per-image error decomposition, and refine the fast-mode positioning in usage.md, the design document, and README.

- Document that nearest/bilinear deposit accuracy is set by the deposit mode and N rather than output resolution, and that the still-frame error is dominated by a small number of resolved bright cores while the flux-dominant faint texture is much better than the global relative L2.
- Add the temporal-coherence caveat: nearest deposition jumps by 1/N output pixel per axis across supersampled-cell boundaries, so fast mode is not qualified as a temporally coherent movie path; bilinear removes the centroid jump but broadens the profile.
- Add scripts/fast_mode_error_decomposition.py, which ranks the largest |fast-base| RGB samples and partitions the error by a disjoint base-value range.
2026-09-26 01:02:41 -04:00

99 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Decompose fast-mode-versus-standard HDR error by scalar RGB sample value.
The unweighted whole-image relative L2 is dominated by a small number of very
bright PSF cores, so it is not a good proxy for how the dense faint-star
texture is reproduced. FITS RGB channels are flattened into scalar samples.
This tool reports, for each fast-mode FITS image:
* the cumulative fraction of ||fast-base||^2 contributed by the largest |d|
RGB channel samples, alongside their share of the baseline linear-RGB sum;
* local relative RMS, signed bias, and share of base energy and linear-RGB
sum for a disjoint partition of base-value ranges.
Usage:
python3 scripts/fast_mode_error_decomposition.py BASE.fits FAST.fits [...]
"""
import sys
import numpy as np
def read_primary_fits(path):
with open(path, 'rb') as stream:
header = b''
while True:
block = stream.read(2880)
if not block:
raise ValueError(f'{path}: truncated FITS header')
header += block
if any(block[i:i + 8] == b'END ' for i in range(0, 2880, 80)):
break
cards = {}
for i in range(0, len(header), 80):
card = header[i:i + 80].decode('ascii', 'replace')
key = card[:8].strip()
if key in ('NAXIS1', 'NAXIS2', 'NAXIS3', 'BITPIX', 'NAXIS'):
cards[key] = int(card[10:30])
if key == 'END':
break
stream.seek(len(header))
count = cards['NAXIS1'] * cards.get('NAXIS2', 1) * cards.get('NAXIS3', 1)
dtype = '>f4' if cards['BITPIX'] == -32 else '>f8'
return np.frombuffer(stream.read(count * np.dtype(dtype).itemsize),
dtype=dtype).astype(np.float64)
def decompose(name, base, fast, ranks=(1e-5, 1e-4, 1e-3, 1e-2, 1e-1)):
diff = fast - base
abs_diff = np.abs(diff)
base_l2_sq = float(np.dot(base, base))
diff_l2_sq = float(np.dot(abs_diff, abs_diff))
base_rgb_sum = float(base.sum())
print(f'\n===== {name} ===== relative_L2={np.sqrt(diff_l2_sq / base_l2_sq) * 100:.4f}%')
order = np.argsort(abs_diff)[::-1]
print('top |d| RGB samples: sq-error energy base energy linear-RGB sum sample share')
for frac in ranks:
k = max(1, int(frac * base.size))
idx = order[:k]
print(f' top {frac * 100:8.4f}%: {np.dot(abs_diff[idx], abs_diff[idx]) / diff_l2_sq * 100:7.3f}%'
f' {np.dot(base[idx], base[idx]) / base_l2_sq * 100:9.3f}%'
f' {base[idx].sum() / base_rgb_sum * 100:8.3f}%'
f' {k / base.size * 100:9.4f}%')
nonzero = base[base > 0]
quantiles = np.quantile(nonzero, [0.5, 0.9, 0.99, 0.999])
edges = np.unique(np.concatenate(
([-np.inf, 1e-6, 1e-3, 1e-1], quantiles, [np.inf])))
print('base-value range (disjoint):'
' rel_RMS signed bias base energy % linear-RGB sum % samples')
for low, high in zip(edges[:-1], edges[1:]):
mask = (base > low) & (base <= high)
if not mask.any():
continue
bin_diff = diff[mask]
bin_base = base[mask]
rel = np.sqrt(np.dot(bin_diff, bin_diff) / np.dot(bin_base, bin_base))
bias = bin_diff.sum() / bin_base.sum()
lo = '-inf' if low == -np.inf else f'{low:.3g}'
hi = 'inf' if high == np.inf else f'{high:.3g}'
print(f' [{lo},{hi}): {rel * 100:9.4f}% {bias * 100:+10.4f}%'
f' {np.dot(bin_base, bin_base) / base_l2_sq * 100:8.3f}%'
f' {bin_base.sum() / base_rgb_sum * 100:7.3f}% {int(mask.sum())}')
def main(argv):
if len(argv) < 3:
print(__doc__)
return 2
base = read_primary_fits(argv[1])
for path in argv[2:]:
fast = read_primary_fits(path)
if fast.shape != base.shape:
raise ValueError(f'{path}: shape {fast.shape} != base {base.shape}')
decompose(path.rsplit('/', 1)[-1], base, fast)
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))