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:
1 parent
85fce1bdeb
commit
9cd933d1f8
12 files changed
+1280
-7
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user