Feat: Add soft-clip tone mapping with legacy Reinhard
Replace the per-channel Reinhard display transform with a parameterized soft clip T_p(x) = tanh(x^p)^(1/p), default softclip p=2, exposed through --tone-map and --tone-map-p. Keep --tone-map reinhard bit-compatible with the previous x/(1+x) curve for existing images and reject combining it with an explicit --tone-map-p. Route the primary image and the mesh overlay through the same ToneMapSettings; the linear HDR FITS writer stays pre-tone-map. Add a focused optics-linked tone-map test target, CLI success/error coverage, and document the display operator versus the Moffat effective PSF.
This commit is contained in:
1 parent
7399eb6904
commit
e31e1ed27e
10 files changed
+359
-30
No files matched your search
@@ -88,10 +88,15 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
|
||||
hdr_available = '--hdr-output' in help_text
|
||||
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 '--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.
|
||||
# Using it (rather than the former 0.1) keeps the soft-clip default from
|
||||
# saturating the whole frame, so the image comparisons below stay
|
||||
# discriminating and the mesh overlay remains visible.
|
||||
common = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 64,
|
||||
'--height', 48, '--fov-deg', 80, '--exposure', 0.1,
|
||||
'--height', 48, '--fov-deg', 80, '--exposure', 1e-3,
|
||||
'--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4]
|
||||
|
||||
def render(name, *options):
|
||||
@@ -129,6 +134,16 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
|
||||
assert render('partial', partial, value) == render(
|
||||
'complete', '--look-ra-deg', ra, '--look-dec-deg', dec)
|
||||
|
||||
# Tone-map selection: the default must equal explicit softclip p=2, the
|
||||
# hardness must be adjustable, and legacy Reinhard must remain available
|
||||
# while producing a visibly different image.
|
||||
softclip2 = render('tonemap_softclip2', '--tone-map', 'softclip',
|
||||
'--tone-map-p', 2)
|
||||
assert softclip2 == default
|
||||
assert softclip2 == render('tonemap_softclip', '--tone-map', 'softclip')
|
||||
assert render('tonemap_p1', '--tone-map', 'softclip', '--tone-map-p', 1)
|
||||
assert render('tonemap_reinhard', '--tone-map', 'reinhard') != softclip2
|
||||
|
||||
# --draw-mesh must leave the primary image untouched and only add a
|
||||
# mesh-overlay sibling.
|
||||
baseline = render('mesh_base')
|
||||
@@ -182,6 +197,14 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
|
||||
(['--observer-track', 'missing.csv', '--observer-velocity', 0, 0, 0], 'cannot be combined'),
|
||||
(['--frames-dir', tmp, '--look-ra-deg', 0], 'cannot be combined'),
|
||||
(['--lens-map-input', 'missing.grlens', '--camera-roll-deg', 0], 'cannot be combined'),
|
||||
(['--tone-map', 'unknown'], None),
|
||||
(['--tone-map'], None),
|
||||
(['--tone-map-p'], None),
|
||||
(['--tone-map-p', 0], None),
|
||||
(['--tone-map-p', 0.5], None),
|
||||
(['--tone-map-p', 'nan'], None),
|
||||
(['--tone-map-p', 'inf'], None),
|
||||
(['--tone-map', 'reinhard', '--tone-map-p', 2], 'applies only'),
|
||||
]
|
||||
if backend == 'schwarzschild':
|
||||
errors += [(['--observer-position', 1.5, 0, 0, '--observer-velocity', -0.5, 0, 0], 'capture cutoff'),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/* Regression tests for the display tone mapping in src/optics.c.
|
||||
*
|
||||
* These call the production public functions so the test cannot drift from the
|
||||
* implementation; no formula is duplicated here. The test needs neither a
|
||||
* catalog, ray tracing, a GPU, nor image files, and it builds in both the
|
||||
* ENABLE_PNG=1 and ENABLE_PNG=0 configurations. */
|
||||
#include "optics.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(int condition, const char *message)
|
||||
{
|
||||
if (!condition) {
|
||||
fprintf(stderr, "FAIL: %s\n", message);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
static int close_to(double value, double expected, double tolerance)
|
||||
{
|
||||
return fabs(value - expected) <= tolerance;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const double grid[] = {0.0, 1e-12, 1e-9, 1e-6, 1e-3, 0.01, 0.1, 0.25,
|
||||
0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 10.0, 1e6};
|
||||
const size_t grid_count = sizeof grid / sizeof grid[0];
|
||||
const double hardness[] = {1.0, 2.0, 4.0};
|
||||
const double small[] = {1e-12, 1e-9, 1e-6};
|
||||
const double negative[] = {-1e-12, -0.5, -1.0, -1e6};
|
||||
const ToneMapSettings softclip2 = {TONE_MAP_SOFTCLIP, 2.0};
|
||||
const ToneMapSettings reinhard = {TONE_MAP_REINHARD, 2.0};
|
||||
|
||||
/* Basic properties for every supported softclip hardness. */
|
||||
for (size_t h = 0; h < 3; ++h) {
|
||||
const ToneMapSettings settings = {TONE_MAP_SOFTCLIP, hardness[h]};
|
||||
check(tone_map_linear_channel(0.0, &settings) == 0.0,
|
||||
"softclip T(0) == 0");
|
||||
check(tone_map_linear_channel(INFINITY, &settings) == 1.0,
|
||||
"softclip T(+infinity) == 1");
|
||||
check(tone_map_linear_channel(1e300, &settings) == 1.0,
|
||||
"softclip overflow saturates at 1");
|
||||
for (size_t n = 0; n < sizeof negative / sizeof negative[0]; ++n)
|
||||
check(tone_map_linear_channel(negative[n], &settings) == 0.0,
|
||||
"softclip negative input maps to 0");
|
||||
double previous = tone_map_linear_channel(grid[0], &settings);
|
||||
for (size_t i = 1; i < grid_count; ++i) {
|
||||
const double current = tone_map_linear_channel(grid[i], &settings);
|
||||
check(isfinite(current) && current >= 0.0 && current <= 1.0,
|
||||
"softclip output is finite and bounded");
|
||||
check(current >= previous, "softclip is monotonically non-decreasing");
|
||||
previous = current;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof small / sizeof small[0]; ++i) {
|
||||
const double ratio =
|
||||
tone_map_linear_channel(small[i], &settings) / small[i];
|
||||
check(close_to(ratio, 1.0, 1e-9),
|
||||
"softclip T(x)/x tends to 1 for small x");
|
||||
}
|
||||
}
|
||||
|
||||
/* Known double-precision values of T_2(x) = sqrt(tanh(x^2)). */
|
||||
check(close_to(tone_map_linear_channel(0.5, &softclip2),
|
||||
0.49489257663023107, 1e-12), "T2(0.5) value");
|
||||
check(close_to(tone_map_linear_channel(1.0, &softclip2),
|
||||
0.8726936208978296, 1e-12), "T2(1.0) value");
|
||||
check(close_to(tone_map_linear_channel(2.0, &softclip2),
|
||||
0.9996645936208139, 1e-12), "T2(2.0) value");
|
||||
|
||||
/* NULL settings select the production default softclip, p = 2. */
|
||||
check(tone_map_linear_channel(0.5, NULL) ==
|
||||
tone_map_linear_channel(0.5, &softclip2),
|
||||
"NULL settings default to softclip p=2");
|
||||
|
||||
/* Both operators must sanitize non-finite and nonpositive inputs: the
|
||||
* public contract is a finite result in [0, 1] and lround() must never see
|
||||
* NaN. This includes Reinhard, whose raw formula would map x < -1 to white
|
||||
* and produce NaN for +Infinity or NaN. */
|
||||
const ToneMapSettings operators[] = {softclip2, reinhard};
|
||||
for (size_t i = 0; i < sizeof operators / sizeof operators[0]; ++i) {
|
||||
check(tone_map_linear_channel(NAN, &operators[i]) == 0.0,
|
||||
"NaN maps to 0 for every operator");
|
||||
check(tone_map_linear_channel(INFINITY, &operators[i]) == 1.0,
|
||||
"+infinity maps to 1 for every operator");
|
||||
check(tone_map_linear_channel(-2.0, &operators[i]) == 0.0,
|
||||
"negative input maps to 0 for every operator");
|
||||
check(tone_map_linear_channel(-0.5, &operators[i]) == 0.0,
|
||||
"small negative input maps to 0 for every operator");
|
||||
check(tone_map_srgb8_channel(NAN, &operators[i]) == 0,
|
||||
"NaN 8-bit output is black");
|
||||
check(tone_map_srgb8_channel(INFINITY, &operators[i]) == 255,
|
||||
"+infinity 8-bit output is white");
|
||||
}
|
||||
|
||||
/* Reinhard must reproduce the pre-soft-clip formula bit for bit. */
|
||||
check(tone_map_linear_channel(0.5, &reinhard) == 0.5 / (1.0 + 0.5),
|
||||
"reinhard x=0.5 -> 1/3");
|
||||
check(tone_map_linear_channel(1.0, &reinhard) == 1.0 / (1.0 + 1.0),
|
||||
"reinhard x=1.0 -> 1/2");
|
||||
check(tone_map_linear_channel(2.0, &reinhard) == 2.0 / (1.0 + 2.0),
|
||||
"reinhard x=2.0 -> 2/3");
|
||||
|
||||
/* The 8-bit path is what the PNG and PPM writers actually emit. */
|
||||
check(tone_map_srgb8_channel(0.0, &softclip2) == 0,
|
||||
"softclip p=2 x=0 is black");
|
||||
check(tone_map_srgb8_channel(2.0, &softclip2) == 255,
|
||||
"softclip p=2 x=2 saturates the white channel");
|
||||
check(tone_map_srgb8_channel(2.0, &reinhard) != 255,
|
||||
"reinhard x=2 is not fully saturated");
|
||||
check(tone_map_srgb8_channel(0.5, &softclip2) >
|
||||
tone_map_srgb8_channel(0.5, &reinhard),
|
||||
"softclip is brighter than reinhard at x=0.5");
|
||||
|
||||
if (failures != 0) {
|
||||
fprintf(stderr, "%d tone-map assertion(s) failed\n", failures);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
puts("tone-map tests passed");
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Reference in new issue
Block a user