Feat: Add optional three-channel sensor bloom model

Add an opt-in, post-processing limited-response model applied to the
finished linear HDR before tone mapping. Each RGB channel is processed
independently and isotropically: overflow above E spreads to the eight
neighbours with a fixed 9-point stencil, while the rest is absorbed or
lost at the image boundary. The synchronous ping-pong update uses a
monotonic bounding box and a row-parallel, deterministic reduction; the
conservative round bound reserves fp guard rounds inside a 4096 hard
limit and fails before touching HDR when exceeded.

Expose --sensor-bloom-limit E and --sensor-bloom-transfer e (both
required together, default disabled), validate them before expensive
initialization, and route every output path through the same hook in
write_frame_outputs: raw FITS first, bloom, tone-mapped PNG/PPM, then the
mesh overlay. The raw --hdr-output FITS therefore stays pre-bloom.

Add a standalone unit test (stencil, boundary loss, cascade reference,
symmetry, thread determinism, convergence limits, validation, allocation
failure), CLI integration and regression coverage, an isolated
sensor-bloom-bench target, and document the model in the design, usage,
README, and build docs.
This commit is contained in:
wyj committed 2026-09-27 04:39:38 -04:00
1 parent 85fce1bdeb
commit 9cd933d1f8
12 files changed
+1280 -7

No files matched your search

+89
View File
@@ -8,6 +8,28 @@ import sys
import tempfile
import zlib
def fits_max(path):
"""Largest positive sample in the renderer's three-plane float FITS."""
data = path.read_bytes()
cards, offset = [], 0
while True:
block = data[offset:offset + 2880]
assert len(block) == 2880, f'truncated FITS header: {path}'
offset += 2880
cards.extend(block[i:i + 80] for i in range(0, 2880, 80))
if any(card.startswith(b'END') for card in cards[-36:]):
break
values = {}
for card in cards:
if card[8:10] == b'= ':
values[card[:8].decode().strip()] = card[10:30].decode().strip()
shape = tuple(int(values[f'NAXIS{axis}']) for axis in (1, 2, 3))
count = shape[0] * shape[1] * shape[2]
payload = data[offset:offset + count * 4]
assert len(payload) == count * 4, f'truncated FITS payload: {path}'
return max(struct.unpack(f'>{count}f', payload))
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD
ENV = dict(os.environ, OMP_NUM_THREADS='4')
@@ -89,6 +111,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'):
assert option in help_text
assert '--tone-map' in help_text and '--tone-map-p' in help_text
assert '--sensor-bloom-limit' in help_text
assert '--sensor-bloom-transfer' in help_text
assert '--observer-inward-speed' not in help_text
assert '_mesh.' in help_text, help_text
# The synthetic grid is calibrated for the renderer's default exposure.
@@ -205,6 +229,17 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
(['--tone-map-p', 'nan'], None),
(['--tone-map-p', 'inf'], None),
(['--tone-map', 'reinhard', '--tone-map-p', 2], 'applies only'),
(['--sensor-bloom-limit', 1], 'specified together'),
(['--sensor-bloom-transfer', 0.5], 'specified together'),
(['--sensor-bloom-limit', 0], None),
(['--sensor-bloom-limit', -1], None),
(['--sensor-bloom-limit', 'nan'], None),
(['--sensor-bloom-limit', 'inf'], None),
(['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', -0.1], None),
(['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 1], None),
(['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 1.5], None),
(['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 'nan'], None),
(['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 'inf'], None),
]
if backend == 'schwarzschild':
errors += [(['--observer-position', 1.5, 0, 0, '--observer-velocity', -0.5, 0, 0], 'capture cutoff'),
@@ -279,6 +314,60 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert image_payload(hdr_mesh_output) == baseline
assert image_payload(tmp / f'{backend}_hdr_mesh_mesh.{ext}') != baseline
# Sensor-bloom integration runs in every build. With linear HDR the
# response limit is derived from the rendered peak; without it the
# fixture's calibrated exposure puts the display shoulder near 1.0,
# which is guaranteed to saturate this scene, so the default ENABLE_HDR=0
# suite still exercises the output-pipeline hook. The limit is placed
# well below the peak so the clamped region falls in the tone map's
# sensitive range and the display comparison stays discriminating.
if hdr_available:
base_peak = fits_max(base_fits)
assert base_peak > 0.0
bloom_limit = base_peak / 100.0
else:
bloom_limit = 1.0
hdr_args = ['--hdr-output'] if hdr_available else []
bloom_output = tmp / f'{backend}_bloom.{ext}'
bloom_run = run(binary, *common, *hdr_args,
'--sensor-bloom-limit', bloom_limit,
'--sensor-bloom-transfer', 0.5,
'--output', bloom_output)
assert image_payload(bloom_output) != baseline
assert 'Sensor bloom:' in bloom_run.stderr, bloom_run.stderr
report = bloom_run.stderr.split('Sensor bloom:', 1)[1].splitlines()[0]
fields = dict(token.split('=', 1) for token in report.split() if '=' in token)
assert int(fields['saturated']) > 0, report
assert int(fields['iterations'].split('/')[0]) >= 1, report
assert float(fields['peak']) >= bloom_limit, report
if hdr_available:
# The raw FITS is written before the model runs, so it stays
# byte-identical to the baseline even though the display changes.
bloom_fits = tmp / f'{backend}_bloom_HDR.fits'
assert bloom_fits.exists(), bloom_run.stderr
diff = subprocess.run([sys.executable, str(FITSDIFF), str(base_fits),
str(bloom_fits)], capture_output=True, text=True)
assert diff.returncode == 0, diff.stdout + diff.stderr
assert 'mismatches=0 max_abs=0 max_rel=0' in diff.stdout, diff.stdout
# The mesh overlay is drawn on the already-bloomed frame, so the primary
# image is unchanged by --draw-mesh and the diagnostic lines do not feed
# back into the overflow model.
bloom_mesh_output = tmp / f'{backend}_bloom_mesh.{ext}'
run(binary, *common, *hdr_args, '--sensor-bloom-limit', bloom_limit,
'--sensor-bloom-transfer', 0.5, '--draw-mesh',
'--output', bloom_mesh_output)
assert image_payload(bloom_mesh_output) == image_payload(bloom_output)
bloom_mesh_sibling = tmp / f'{backend}_bloom_mesh_mesh.{ext}'
assert bloom_mesh_sibling.exists()
assert image_payload(bloom_mesh_sibling) != image_payload(bloom_output)
if hdr_available:
bloom_mesh_fits = tmp / f'{backend}_bloom_mesh_HDR.fits'
diff = subprocess.run([sys.executable, str(FITSDIFF), str(base_fits),
str(bloom_mesh_fits)], capture_output=True, text=True)
assert diff.returncode == 0, diff.stdout + diff.stderr
assert 'mismatches=0 max_abs=0 max_rel=0' in diff.stdout, diff.stdout
if backend == 'schwarzschild':
# Two inward-looking free-fall samples at r=6.2696 and r=3.1593.
# At 16:9 the latter frame finishes in generation 0, while the