CORONA IMAGE EDITOR

Reverse engineering Corona's tone mapping

Chaos does not publish the formulas behind the Corona Image Editor. They are recovered empirically: export a swatch, measure the output, and fit a curve. This project applies that method to every operator documented here.

01

Methodology

Each operator is fitted in isolation. Every other operator is neutralized, the target operator is swept across a grid of parameter values, and a linear gray ramp plus a color-patch swatch is exported. Exports run headless through CoronaImageCmd.exe, using a patched colorMap.pipeline coffi blob.

From each export, the per-channel curve t(g) = B_gray⁻¹(output(g)) is recovered, where B_gray is the neutral gray response. Cancelling that baseline yields each operator's in-place curve exactly. The fit on Simple Exposure holds to within 0.5% maximum error.

Nonlinear operators require two or three exports at different values to pin the parameter scaling. Linear operators require only one. The recovered curves are stored as lookup tables, interpolated in log space over a 1e-4 to 1e4 ramp, rather than as closed-form equations. Tables keep the implementation faithful to Corona's output.

02

Application order

Operators apply from top to bottom. This order is the reverse of the corona.cm.pipeline serialization. For the post effects, the order bloom → sharpen → tone map matches CoronaImageCmd to about two LSBs.

  1. 1.Contrast
  2. 2.Green-Magenta Tint
  3. 3.[sRGB] Lift / Gamma / Gain
  4. 4.Photographic Exposure
  5. 5.Saturation
  6. 6.Simple Exposure
  7. 7.Tint
  8. 8.Vignette (spatial)
  9. 9.White Balance (improved)
  10. 10.White Balance (legacy)
  11. 11.Advanced Filmic
  12. 12.[sRGB] Curves
  13. 13.Filmic
  14. 14.Tone Curve
  15. 15.Reinhard Highlight Compression
  16. 16.ACES OT
  17. 17.LUT (3D)
03

Render elements

Corona writes each render element as a set of .R/.G/.B/.A channels in the EXR. Only the Beauty and CESSENTIAL elements receive tone mapping and post-processing; every other element family is shown raw. The selector also filters out the auxiliary buffers that the denoiser writes.

ChannelTreatmentMeaning
BEAUTYpostThe final image. Always present. Tone-mapped and post-processed.
AlpharawThe built-in alpha channel. Always present. Shown raw.
CESSENTIAL_*postAdditive light transport: Direct, Indirect, Reflect, Refract, Translucency, Volumetrics, Emission. The only render elements that receive tone mapping and post-processing.
CGeometry_*rawGeometry data: normals, UVW coordinates, velocity, world position, Z-depth. Shown raw.
CInfo_*rawRender diagnostics: sampling focus, time per sample, render stamp, network-render debugging. Shown raw.
CMasking_*rawMasks: ID, mask, wire color, cryptomatte, AI enhancer. Shown raw.
CShading_*rawShading components: albedo, source color, raw component, caustics, LightMix, LightSelect, shadows, and others. Shown raw.
CTexmaprawArbitrary texmap evaluation on scene surfaces. Shown raw.
Sqr*filteredSquared radiance of each element. Variance input for the denoiser (variance = E[x²] − E[x]²).
Denoised*filteredThe denoiser's output copies of each element.
CORONA_FB_*filteredDenoiser feedback and importance maps.
HybridfilteredCombined input for the hybrid (OptiX) denoiser.
VisibleDiffusefilteredDiffuse albedo buffer used as denoiser guidance.
VisibleNormalsfilteredSurface normals (R/G/B = XYZ) used as denoiser guidance.

The filtered channels are all denoiser auxiliary buffers: squared radiance and denoiser-output copies for variance estimation, plus albedo, normals, and hybrid inputs used as guidance. They are not part of the tone-mapping flow and are omitted from the selector.

The tone-mapping classification comes from the corona.elements header attribute, which lists each element as Name|Name|Type|Flags. The ColorMapped flag marks elements that receive tone mapping; the channel name is used only as a fallback when that attribute is absent.

04

Operators

There are 17 tone-mapping operators. Each card is generated from the operator catalog — the same data drives the editor UI, so the documentation and the controls cannot drift apart.

Contrast

contrastreverse-engineered

A pivot S-curve that compresses the low end and expands (or flattens) the high end around a neutral pivot. Neutral is 1.0.

out = QB(t)/QA(t) - 0.1425081342
r = P1(x)/P2(x)   // cubic ratios
v>=1: t = P6C/(1 + exp((r - 1.3757956)*xp)) + P70
v<1:  t = ln(clamp(P6C/((r - 0.875795603) - P70) - 1, P74, P78))/xp + 0.5
prep: xp = v>=1 ? -v : (v-1)*10;  P6C = 0.5/(0.5 - 1/(exp(-xp/2) + 1));  P70 = 0.5 - 0.5*P6C
identity mask x<=1e-6 / x>=0.999998987; max(0)   // EXACT Legion::ContrastOperator::mapImpl
  • ·EXACT closed form transliterated from the real Legion::ContrastOperator (mapImpl @0x1800a9740 + prepare @0x1800aa610); verified by calling the DLL's mapImpl in-process and bit-comparing (agreement 3.5e-7 = float rounding).
  • ·Replaced the earlier 19x512 measured LUT (removed in commit 0e796fc). Residuals seen against some older CIE fixture rows (~1.5e-4) are the legacy chain's other default-op evaluation wobble, not this operator.
  • ·All constants read bit-exact from .rdata; evidence trail: RE/REPORT_CONTRAST_CLOSED_FORM.md in the RE tree.
  • ·contrast < 1 -> lift shadows, flatten highlights (low-contrast film); contrast > 1 -> crush shadows, expand highlights.

Green-Magenta Tint

gmtreverse-engineered

A green/magenta rebalance via per-channel power law. Positive tint darkens R/B and brightens G in the midtones (green), and does the reverse in HDR highlights (magenta); negative inverts the axis.

out_c = in_c^k\nE  = (1-|v|) + 1.4142135382*max(v,0) + 0.7071067691*max(-v,0)
E2 = (1-|v|) + 0.7071067691*max(v,0) + 1.4142135382*max(-v,0)
out_R = out_B = sign(x)*|x|^E ;  out_G = sign(x)*|x|^E2
  • ·EXACT (decompiled mapImpl): |v| <= 1e-4 is a dead zone; magnitudes are raised and signs restored.
  • ·The G channel uses the SWAPPED constant pair (not 1/E): at v=0.5, E=1.20711, E2=0.85355 (validated against the CIE at rmse 8e-5).
  • ·Exponent >1 darkens values below 1 and brightens values above 1, so positive tint reads green in midtones and magenta in highlights.

Lift / Gamma / Gain

lggreverse-engineered

The classic lift/gamma/gain colour wheels collapsed to a neutral axis: three sequential 1D curves. Neutral is lift=1, gamma=1, gain=1.

lift' = 2 - lift
x1 = max(0, (x-1)*lift' + 1) * gain
out = x1^(1/gamma)
  • ·EXACT (decompiled prepareForApplicationImpl + mapImpl): one multiply-add and one pow - the earlier three sRGB-space LUTs are gone, along with their round-trip residue.
  • ·lift' = 2-lift comes from the prepare function; the gamma exponent is stored as 1/gamma.
  • ·The legacy property that drives this operator in the CLI could not be confirmed, so this form is validated from the disassembly rather than a ramp sweep.

Photographic Exposure

isoreverse-engineered

Camera-style exposure: a single gain derived from ISO, f-stop and shutter speed.

factor = 683 / ((fstop^2 * 78) / ((0.65 / shutter) * iso))
       = 5.6916 * iso / (shutter * fstop^2)
out = in * factor
  • ·EXACT (decompiled prepareForApplicationImpl): validated against a CIE gray-ramp run (iso 50, f/8, 1/200 -> pure gain 0.022232, predicted 0.0222327).
  • ·Defaults (iso 100, f/8, 1/200 -> 0.0445) match CoronaImageCmd's defaults; the .cxr metadata carries corona.cm.iso / fstop / shutterspeed and is applied when present.
  • ·The operator is gated by colorMap.usePhotographic in Corona's config; the enable toggle here plays that role.

Saturation

saturationreverse-engineered

Additive HSV-style saturation: L = max(in, 0); S = (max - min) / max; S' = max(S + sat, 0). Neutral is 0.

L    = max(rgb, 0)                       // input zero-clamp (DLL maxps, runs first)
gray = max(L);  minv = min(L)
S    = (gray - minv) / gray              // 0 when gray == 0
amt  = (S <= 1e-4) ? 0 : min(sat, +1)    // guard on the INPUT S; property cap +1
Sp   = max(S + amt, 0)                   // the ONLY clamp (no upper clamp)
kk   = Sp / S                            // chroma scales by Sp/S
out  = gray + (L - gray) * kk            // achromatic returns L; NO [0, gray] clamp

sat > 0 -> saturate; sat < 0 -> desaturate; S + sat <= 0 collapses to gray
  • ·EXACT (decompiled Legion::SaturationOperator::mapImpl @0x1800a9dd0 + the identical inline copy in evalRgb; validated by a 14-row x 13-amount CoronaImageCmd battery: 546 lanes under the capped model, 36/36 collapse rows bit-equal to the literal-gray control rows, 0 failures).
  • ·Corona's saturation is ADDITIVE in HSV saturation, not the linear gray + (rgb-gray)*(1+s) mix. Because chroma scales by Sp/S, low-saturation (pastel) colors get a much stronger boost than already-vivid colors, which is why the CIE looks 'more intense' than a plain (1+s) mix.
  • ·The gray anchor is the per-pixel max channel, not a luminance weighting (verified: red@1.0 desaturates to 1.0, not Rec.709's 0.21).
  • ·The guard is S <= 1e-4 (const bits 0x38d1b717) on the INPUT saturation: guarded pixels keep the zero-clamped input. The amount is range-capped at +1: +1, +1.25 and +2 render pixel-identical in the CIE.
  • ·No upper clamp on S' (S' = 1.25 is used at sat +0.5) and no [0, gray] output clamp: the operator emits negatives, and it is the CIE raw save that reads them as 0 afterwards.

Simple Exposure (EV)

exposurereverse-engineered

A pure linear gain in EV stops. Neutral is 0.

out = in * 2^EV
  • ·Verified to <= 0.5% max error over the full ramp — the simplest, most exact operator in the set.

Tint

tintreverse-engineered

Multiplies each channel by a tint colour (diagonal gains). White = neutral.

out = [r*tr, g*tg, b*tb],  (tr, tg, tb) = tint colour in [0, 1]
  • ·The colour is stored as tintR/tintG/tintB on the instance (derived from the hex picker).
  • ·Decoded from the EXR/CXR metadata as a vec3 when present.

Vignette

vignettereverse-engineered

A radial darkening toward the corners. Multiplies each pixel by a smootherstep falloff of its distance from the image centre.

S = 1/max(w,h);  Bx = S*w/2;  By = S*h/2
k1 = max(0, 1-intensity)^2;  k2 = exp(2.5*falloff) / (2/max(1,intensity) - k1)
t  = clamp( ((x*S - Bx)^2 + (y*S - By)^2 - k1) * k2, 0, 1 )
u2 = (1-t)^4
m  = 6*u2^5 - 15*u2^4 + 10*u2^3        (= smootherstep(u2))
out = rgb * m                          (identity when intensity <= 0)
  • ·EXACT: transliterated from the real Legion::VignetteOperator (mapImpl @0x1800a9ed0 + prepare @0x1800ab310); validated against flat-input CIE differentials at 64x64 / 256x256 / 300x200 (rmse 2e-7 small-signal; the few-% big-signal residual is downstream transfer curvature).
  • ·The f=1 disc of radius (1-intensity)*max(w,h) is reproduced exactly; k2 = exp(2.5*falloff)/(2/max(1,intensity) - k1) fits the measured maps.
  • ·The radial falloff is normalized by max(w,h) (not the old elliptical w/h normalization): resolution-independent, and exposure to corners depends on the image aspect.
  • ·The GPU point-op path builds its vignette LUT from this same apply along the centre-to-corner diagonal.

White Balance (Improved)

wbreverse-engineered

Full 3x3 matrix white balance; 6500 K is neutral. Matrices are tabulated from the real LegionLib export.

out = M(kelvin) . rgb     (full 3x3; interpolation between 23 anchors)
M = Legion::colorAdaptationTm(kelvin, 6500)  - the exact DLL export
  • ·EXACT: matrices tabulated from the real LegionLib export at 23 log-spaced anchors (2000..99999 + 6500 identity); interpolated element-wise.
  • ·A full spectral blackbody-to-XYZ port is available (see whiteBalanceGains in the tonemap core); the anchor table keeps it to a 3x3 multiply per pixel.

White Balance (Legacy)

wblegacyreverse-engineered

The older kelvin -> RGB-gain white balance, kept for compatibility. 6500 K is neutral.

out = rgb * gains(kelvin)
gains = white(6500)/white(kelvin), mean-normalized
white(T) = M3 . ( integral(planck(T, l) * CMF(l) dl) ), l = 360..830nm @5nm
  • ·EXACT: a JS port of Legion::colorAdaptation - Planck blackbody x CIE 1931 CMFs x the XYZ->sRGB matrix, computed per temp and cached.
  • ·Validated against the real DLL export AND the CIE: gains(5000,6500) = [0.7957630, 0.9477397, 1.2564972] matches to 1.5e-6.

Advanced Filmic

advfilmicreverse-engineered

A film-style contrast curve with an independent toe (shadow crush) and shoulder (highlight clip), plus a shoulder angle.

b   = toeStrength * 0.5 * toeLength^2.2   (toe black point)
cap = 1 - 0.8912 * shoulderLength          (shoulder white point)
out = clamp(in - b, 0, cap - b/2)          (toe pulls the cap down by b/2)
White point: the operator always clamps to 1.0 (values above 1 are
  clipped to white even at shoulder strength 0).
shoulderAngle: gain = 1/(1 + (strength/2)*angle); shadows scaled by
  gain, highlights lifted to 1 - gain (knee at cap).
strength >= 25 is a hard clip; below that a ~0.35 blend toward the cap.
  • ·Reverse-engineered from headless CIE sweeps: toe, hard-clip shoulder, angle rotation, and the toe+shoulder composition are verified to 4e-4.
  • ·The soft shoulder regime (strength < 25) is a ~0.35 blend approximation (exact soft curve not recovered).

Curves

curvesreverse-engineered

A user-defined 1D curve applied per channel. Control points are interpolated with a Catmull-Rom spline.

Piecewise cubic Hermite between control points (p0, p1):
  cubic basis h00/h10/h01/h11 with tangents:
  interior m = secant across neighbouring points; endpoints one-sided
Linear extrapolation beyond the first/last point.
  • ·EXACT evaluator ported from the decompiled Curve::operator() (Legion's monotone piecewise cubic); validated against CLI outputs.
  • ·The operator LOADS WITH NO POINTS (empty list = neutral identity via TM.curves). The CIE's NOIR look control points remain available as TM.NOIR_CURVE_POINTS but are never seeded automatically.

Filmic

filmicreverse-engineered

A two-knob filmic response: highlight compression plus a 'rich shadows' lift.

t = (1.0003055334*x + 0.0021391737)*x / ((0.4303440154 + x)*x + 0.0360290632)
m = min(1, x)
w = m*(p_hi - p_lo) + p_lo   with p_hi = highlightCompression, p_lo = richShadows
out = (t - x)*w + x
  • ·EXACT (decompiled mapImpl + prepare): a rational core blended toward identity; both sliders are raw values (validated against the CIE at rmse 7e-5 for richShadows=1).
  • ·The 256-point LUTs are gone - the closed form replaces them.

Tone Curve

tonecurvereverse-engineered

Four independent zone curves (shadows, darks, lights, highlights), each a 1D LUT.

out = shadows( darks( lights( highlights(in) ) ) )
each zone: -1 .. +1, 0 neutral, applied via its own 1D LUT
  • ·Four sequential 1D LUTs, one per zone.

Reinhard Highlight Compression

hcreverse-engineered

Reinhard-style highlight compression to prevent blown highlights.

out = x * (1 + k*x) / (1 + x),  k = 1/hc^2
  • ·EXACT (decompiled mapImpl + prepare): the prepare stores 1/v^2; validated against the CIE (highlightCompression=2.0 ramp, rmse 4e-5).
  • ·Lower hc = stronger compression of the highlight tail.

ACES OT

acesreverse-engineered

The ACES output transform: a 3x3 colour matrix followed by a per-channel S-curve, blended by opacity.

c  = M_in . rgb     [0.59719 0.35458 0.04823 / 0.07600 0.90834 0.01566 / 0.02840 0.13383 0.83777]
cr = (c*(c + 0.0245785993) - 0.0000905370034) / (c*(0.9837290049*c + 0.4329510033) + 0.2380809933)
y  = M_out . cr     [1.60475 -0.53108 -0.07367 / -0.10208 1.10813 -0.00605 / -0.00327 -0.07276 1.07602]
out = lerp(rgb, y, opacity)
  • ·opacity 0 = linear pass-through; opacity 1 = full ACES film look (Corona's default).
  • ·EXACT: the REAL matrices (read from the live constant pool in the DLL's .data tail) and the RRT rational - not the single-matrix fit used before.
  • ·Validated: default-chain gray ramp mean abs diff 3.4e-5; aces(0.5)=0.3743, aces(1.0)=0.6191 match the CIE.
  • ·The post-M_out output clamp is [0,1] (clamp01). The 0.9998354 saturation plateau seen in CIE saves is the legacy powApprox pair's output gs(1.0)=0.999835432 (RE/agents/vs_test probe), applied AFTER this op at chain end; clamping to the plateau here starves the pair (gs(0.9998354)=0.999710083) - see the clamp-order comment in lib/tonemap/corona-tonemap.js.

LUT (3D)

lutnot reverse-engineered

Applies a 3D .cube LUT with trilinear interpolation. Supports gamma-2.2 (CIE default), logarithmic, or linear working space.

encode = default: in^(1/2.2)   log: powApprox( L(in), 1/2.2 )   linear: identity
out = trilinear( LUT, encode )   // size^3 x 3, red-fastest .cube layout
decode = default: out^2.2   log: powf( out, 2.2 )   linear: identity
L(x) = ln(max(x,0)*189.94085693359375 + 2.073321580886841) / 7.849999904632568
final = lerp(in, decode(out), opacity)
  • ·The built-in LUT is Corona's NOIR_OFF_night.cube (33^3). You can load any .cube file.
  • ·Default working space = pure gamma 2.2 (x^(1/2.2) in, ^2.2 out); matched to the CIE by differential runs (rmse 1e-4 on Advantix_100, 1.8e-4 on 400).
  • ·Corona log mode (RE close/04-lut-2table corrected model, spec 02 section 13): the log coordinate L(x) is built first and then runs through the same pipeline-level gamma pair as the default mode - input = powApprox(L(x), 1/2.2) (the Legion fast pow port), output = exact powf(., 2.2). Real-cube CIE log fixture fit rmse 8.4e-5; the earlier 'cube(L(x)) with no post-decode' reading (from an identity-LUT probe, which is blind to an inverse pair) is rejected at 1.47e-2.
  • ·Arbitrary user LUTs/curves cannot be reverse-engineered (they depend on the asset), so this operator consumes the .cube directly.
05

Post-processing

The spatial effects live outside the pipeline blob, in the top-level conf properties. They apply to the linear HDR beauty before tone mapping.

Sharpening & Blurring

blur    = Gaussian, sigma ≈ 0.25 · radius
          (measured: radius 1→0.41, 2→0.55, 4→1.05, 8→2.0, 16→3.9, 32→7.5, 50→11.5)

sharpen = unsharp mask:  out = x + amount · (x − blur(x, radius))
          (same Gaussian as the blur operator, confirmed at equal radius)

order   = blur FIRST, then sharpen  (blur(sharpen(x)) does not match)

The real blur keeps uniform fields intact (DC = 1.0) but attenuates an isolated impulse's peak by about 1/(2·radius)². That is the signature of a multi-resolution blur, not a plain convolution. The sharpen also boosts isolated bright pixels roughly four to five times more than the same delta on mid-gray, and the amount response is super-linear (about amount^1.76). The linear model is correct for low-amplitude detail but under-predicts the highlight and impulse boost.

Bloom & Glare

threshold = hard, per-channel cutoff:
            bright[c] = max(0, x[c] − threshold)

bloom    = Moffat blur of bright, scaled by bloomIntensity
           1/(1 + (d/a)^2)^1.25, a ≈ size% · diag · 0.0055
           (Gaussian-like core + ~1/d^2.5 tail)

glare    = directional power-law streaks, scaled by glareIntensity
           weight w(d) ≈ 0.033 / d^2.43
           streakCount streaks at rotation + 180°·k/N (min 2, perpendicular)
           streakBlur = perpendicular Gaussian, sigma ≈ sqrt((streakBlur · size% · diag · 0.036)^2 + 2^2)
           (2px intrinsic width + a 6.5px/unit blur rate)

chromatic dispersion (colorIntensity / colorShift):
           per-channel bloom WIDTH (a) — energy-conserving, NOT an energy
           multiplier: a_R = a·(1+ci·(0.26·tri−0.13)), a_B = a·(1+ci·(0.45−0.58·tri))
           where tri = |2·shift−1|. At shift 0 R is wider / B narrower;
           at shift 0.5 R narrower / B much wider (blue disperses ~3× red).

Corona computes bloom and glare on a reduced-resolution pyramid. The bloom power-law tail (~1/d^2.5) and the glare ~1/d^2.4 streak decay were fitted from a 9×9-patch head-to-head against the CIE (a pure Gaussian bloom tail is ~30× too broad; a 1/d glare tail is ~60× too strong). Custom apertures map blade counts onto diffraction streaks: a circular aperture yields no streaks; N blades yield N streaks for even N and 2N for odd N.

06

The pipeline blob

A rendered .exr or .cxr embeds its operator stack in a corona.cm.pipeline header attribute: a base64 coffi blob holding the same data as a .conf colorMap.pipeline. The parser locates the zlib streams (0x78 01/5e/9c/da), inflates section 1, and walks the Chaos.*Operator entities. Each operator's enabled flag and field values sit after the next operator's name header.

[256-byte header][zlib §0][zlib §1 (operators)][§2][§3][footer]

fields (per operator):  enabled(bool) + f32/vec3/str values
  for example, SimpleExposureOperator  → { simpleExposure: f32 }
              AdvancedFilmicOperator  → { toeStrength, toeLength, shoulderStrength,
                                           shoulderLength, shoulderAngle }
07

Known limitations

  • ·Structural sRGB conversions. LinearToSrgb and SrgbToLinear have no enabled flag and cannot be disabled. Their net effect is a cross-channel matrix that does not fully cancel on a round-trip. The engine folds them into the Lift/Gamma/Gain and Curves in-place curves.
  • ·Multi-operator composition. The one-at-a-time LUTs are exact in isolation but do not compose exactly when several non-neutral operators are active. Each LUT embeds the full neutral baseline.
  • ·Curves. The engine implements Catmull-Rom through the decoded control points. Corona's exact monotone spline tangent handling is an approximation.
  • ·Arbitrary assets. User LUTs (.cube) and Curves are consumed directly. They cannot be reverse-engineered, because they depend on the asset.
  • ·Denoising, LightMix, and Auto Adjustment. These are not reverse-engineered; the UI keeps placeholders for parity.