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.
99 lines
4.0 KiB
Python
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))
|