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 = LUT_2D(contrast, in)   // 10 contrast stops x 64-point log ramp, linear interp in both dims
contrast < 1 -> lift shadows, flatten highlights (low-contrast film)
contrast > 1 -> crush shadows, expand highlights
  • ·Fitted as a 2D lookup table: 10 contrast values (0, 0.5, 1, 2, 4, 8, 16, 32, 64, 99) x a 64-point log ramp spanning 1e-4 .. 1e4.
  • ·Interpolation is linear in the parameter dimension and in log-space along the value axis (lut1d/lut2d).
  • ·The value ramp is sampled in log space because the underlying Corona curve is applied to linear data over a huge dynamic range.

Green-Magenta Tint

gmtreverse-engineered

A green/magenta rebalance via diagonal channel gains. Positive tint pushes toward magenta (R and B up, G down); negative toward green.

out = [r*gr, g*gg, b*gb]
gr, gg, gb = interp over tint in [-1, 1]  (5 captured samples)
at +1: R 1.35, G 0.84, B 1.35   (magenta)
at -1: R 0.84, G 1.35, B 0.84   (green)
  • ·Captured gains are symmetric around 0: R and B gain with positive magenta, G loses, and vice-versa.
  • ·The catalog stores the UI-facing sign; the engine applies the negation because Corona's slider and the captured gains are sign-inverted.

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.

out = gain( gamma( lift(in) ) )
lift:  0 .. 2   (lifts the black point)
gamma: 0.01 .. 10  (power-like midtone pivot)
gain:  0 .. 2   (scales the white point)
  • ·Each of the three stages is a 2D LUT over its parameter (captured in the sRGB working space).
  • ·The structural sRGB round-trips that surround this operator in Corona's pipeline are folded into these in-place curves, so they do not fully cancel — see the docs' 'Known limitations'.

Photographic Exposure

isoreverse-engineered

An on/off toggle. EV = log2(ISO/100); at the fixed ISO 100 the operator is an identity, so the toggle has no visible effect here.

EV = log2(iso / 100);  at iso = 100 -> EV = 0 -> out = in
  • ·Corona serializes this operator with no numeric fields (enabled flag only), so ISO is pinned to 100 in the reverse-engineered model.
  • ·Measured: ISO 50 -> 100 -> 200 -> 400 gives exactly 2x exposure per stop, confirming the log2 mapping.

Saturation

saturationreverse-engineered

Additive HSV-style saturation: S = (max - min) / max, then S' = clamp(S + sat, 0, 1). Neutral is 0.

gray = max(r, g, b)
minv = min(r, g, b)
S    = (gray - minv) / gray      // original HSV saturation (0..1)
k    = 1 + sat / S               // chroma scales by S'/S = (S+sat)/S
out  = clamp(gray + (rgb - gray) * k, 0, gray)

sat > 0 -> saturate; sat < 0 -> desaturate; sat = -1 -> full grayscale
  • ·Corona's saturation is ADDITIVE in HSV saturation, not the linear gray + (rgb-gray)*(1+s) mix. Because chroma scales by 1/S, low-saturation (pastel) colors get a much stronger boost than already-vivid colors — this 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).
  • ·Fitted from a 12-point saturation sweep (-1.0..+1.0) over the linear swatch through headless CoronaImageCmd: mean abs error ~4e-4 vs the old (1+s) formula's ~1.9.

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 falloff that depends on its normalized distance from the centre.

cx = w/2, cy = h/2
r  = sqrt( ((x-cx)/cx)^2 + ((y-cy)/cy)^2 ) / sqrt(2)   // 0 at centre, 1 at corner
m  = LUT_2D(intensity, r)   // 7 intensity stops x 32 radial samples
out = rgb * m
  • ·The radial falloff is elliptical (normalized by cx/cy) so it is resolution-independent.
  • ·Applied per-pixel (not as a whole-buffer convolution), so it lives in the point-operator loop with x/y coordinates.

White Balance (Improved)

wbreverse-engineered

Colour-temperature rebalance via per-channel gains, interpolated over kelvin. 6500 K is neutral.

out = rgb * gains(kelvin)
gains = interp over 8 kelvin anchors (2000 .. 99999)
e.g. 2000K -> [0.48, 1.08, 6.44], 10000K -> [1.19, 1.10, 0.84]
  • ·The 'improved' variant is Corona's current (Bradford-style) white balance.
  • ·Gains were captured at 8 kelvin stops and interpolated; the improved operator has a much stronger blue channel at low kelvin than the legacy one.

White Balance (Legacy)

wblegacyreverse-engineered

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

out = rgb * gains(kelvin)
gains = interp over 8 kelvin anchors (2000 .. 99999)
e.g. 2000K -> [0.011, 0.055, 2.72], 10000K -> [1.22, 1.02, 0.75]
  • ·Lower-temperature responses differ markedly from the improved variant (stronger red cut, deeper blue).

Advanced Filmic

advfilmicreverse-engineered

A film-style S-curve with independent toe (shadow) and shoulder (highlight) rolloff, plus a shoulder angle.

out = shoulderAngle( shoulderLength( shoulderStrength( toeLength( toeStrength(in) ) ) ) )
toe     -> lifts/lowers shadows with a soft knee
shoulder -> compresses highlights with a soft knee
Each stage is a 1D LUT over its parameter (5 samples each).
  • ·toe/shoulder length and angle are ratio multipliers in Corona's model (confirmed empirically).
  • ·Five sequential 1D LUTs, one per parameter, applied in the order listed above.

Curves

curvesreverse-engineered

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

Between control points (p1, p2):
  t = (x - p1.x) / (p2.x - p1.x)
  out = 0.5 * ( 2*p1.y + (-p0.y + p2.y)*t
        + (2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t^2
        + (-p0.y + 3*p1.y - 3*p2.y + p3.y)*t^3 )
Linear extrapolation beyond the first/last point.
  • ·Corona's exact spline is a monotone piecewise cubic; Catmull-Rom is the current approximation (control-point decoding is exact).
  • ·The default curve is Corona's NOIR look: (0,0), (0.352, 0.1905), (0.7453, 0.1164), (1,1).

Filmic

filmicreverse-engineered

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

out = richShadows( highlightCompression(in) )
highlightCompression: 0 .. 1 (softens the highlight rolloff)
richShadows:          0 .. 1 (adds shadow density)
  • ·Two sequential 1D LUTs over their parameters.

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 = LUT_2D(hc, in)   // 5 hc stops x 64-point log ramp
closed-form approximation:
  out = y * (1 + y / hc^2) / (1 + y)
  • ·The full operator is a 2D LUT over highlightCompression; the closed form is a good approximation used in earlier builds.
  • ·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 . rgb          (3x3 row-sum-normalized matrix)
cr, cg, cb = ACES_curve(c)   (per-channel 1D curve)
out = lerp(rgb, [cr, cg, cb], opacity)

M = [0.7313 0.2294 0.0393]
    [0.0567 0.9311 0.0122]
    [0.0235 0.1185 0.8580]
  • ·opacity 0 = linear pass-through; opacity 1 = full ACES film look (Corona's default).
  • ·The matrix is row-sum-normalized so gray maps to gray; the curve is captured on a 64-point ramp.

LUT (3D)

lutnot reverse-engineered

Applies a 3D .cube LUT with trilinear interpolation. Supports sRGB, logarithmic, or linear working space.

x = encode(in)      // sRGB (default) | log10 | linear
out = trilinear( LUT, x )   // size^3 x 3, red-fastest .cube layout
result = decode(out)
final = lerp(in, result, opacity)
  • ·The built-in LUT is Corona's NOIR_OFF_night.cube (33^3). You can load any .cube file.
  • ·The log working space spans 1e-4 .. 1e4 (approximation of Corona's internal log encoding).
  • ·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    = Gaussian blur of bright, scaled by bloomIntensity
           sigma ≈ size% · diag · 0.09

glare    = directional 1/d-decay streaks, scaled by glareIntensity
           streakCount streaks at rotation + 90° + 360°·k/N

chromatic dispersion (colorIntensity / colorShift):
           per-channel energy multiplier; at shift 0  → R 1.9×, G 1.17×, B 0.70×
                                            shift 0.5 → B dominant

Corona computes bloom and glare on a reduced-resolution pyramid. The single-resolution approximation matches large bright regions but under-predicts the isolation of single pixels. 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.