Image Processing & Computational Photography

Gamma Correction

The nonlinear transfer functions that map light to stored code values and code values back to light, such as sRGB, BT.709 and BT.1886, and why they decide how many visible steps an 8-bit image has.

beginner

Gamma correction is the nonlinear mapping between light and the numbers that represent it. A camera or renderer encodes linear light with a transfer function that is roughly a power law with an exponent near 1/2.2. It stores the result as 8- or 10-bit codes, and a display turns the codes back into light with a roughly inverse power law. The color spaces article gives the sRGB formula and shows why arithmetic on encoded values is not arithmetic on light. This article treats transfer functions as encodings: the standard curves, the reason they exist, and how they spend a fixed number of codes across the range from black to white. That last point decides whether an 8-bit image shows visible steps in dark gradients.

Definition

A transfer function relates a physical quantity of light, such as luminance or a linear RGB component, to a signal value. Video standards distinguish two directions:

  • The opto-electronic transfer function (OETF) maps scene light to a signal at the source. ITU-R BT.709 specifies one for HDTV cameras.
  • The electro-optical transfer function (EOTF) maps a signal to light at the display. ITU-R BT.1886 specifies the reference EOTF for flat-panel studio displays.

Gamma is the exponent of the power law that approximates either curve. Poynton’s Frequently Asked Questions about Gamma (1998, §5) gives the colloquial meaning: the exponent of a CRT’s power-law response of intensity to voltage. Strictly, he applies the term to the exponent at the monitor (§16). As of November 2026, the FAQ page of his website describes the document as about thirty years old and in need of an update; it is cited here for the CRT-era reasoning that the later standards record. Gamma correction is applying the compensating nonlinearity to the signal, which video does at the camera (§6). In image processing the same name is also used for any power-law point operation. scikit-image’s adjust_gamma, for example, says it “Performs Gamma Correction” by computing O=IγO = I^\gamma on values scaled to [0,1][0, 1] (scikit-image 0.25.2, exposure.py). That use is a tone adjustment, covered under point operations, not an encoding.

Intuition

There are two reasons to encode light nonlinearly, and historically they happened to coincide.

The first is the display. Poynton (1998, §5) states that a conventional CRT produces light approximately proportional to the applied voltage raised to the power 2.5. A signal proportional to scene light would therefore come out with its midtones far too dark, so the signal was pre-distorted at the camera with roughly the inverse power.

The second is perception, which matters even without a CRT. Vision responds to luminance nonlinearly. Poynton (§4) gives the example that a patch with 18% of a reference luminance looks about half as bright, and that two patches can be told apart when their intensities differ by about one percent. With only 256 codes available, a code step of a fixed size in light is far too coarse near black, where one percent of the light is tiny, and needlessly fine near white. Spacing the codes more evenly in perceived lightness uses them better. Poynton (§5) calls it “an amazing coincidence” that the CRT’s nonlinearity is close to the inverse of this lightness response: a signal coded for perception is decoded correctly by the CRT.

Flat panels have no electron gun, but the encoding stayed. BT.1886 (2011) records the reason on the display side: for consistent pictures it is desirable that newer display technologies have an EOTF that closely matches the CRT’s. The perceptual argument does not depend on the display at all.

Formal Definition

Power laws

The simplest encoding of a normalized linear value Y∈[0,1]Y \in [0, 1] is a pure power law, and the display inverts it:

V=Y1/γ,Y=Vγ.V = Y^{1/\gamma}, \qquad Y = V^{\gamma}.

Stored with nn bits, the code is k=round⁡((2n−1) V)k = \operatorname{round}\big((2^n - 1)\,V\big). Decoding gives the luminance of code kk, Yk=(k/(2n−1))γY_k = \big(k / (2^n - 1)\big)^\gamma. Linear coding is the case γ=1\gamma = 1. An encoding power law with γ>1\gamma > 1 has infinite slope at black. Poynton (1998, §6) explains the linear segment near black of the Rec. 709 curve as minimizing the effect of sensor noise in practical cameras and scanners, and sRGB has a linear segment too.

sRGB

The sRGB curve of IEC 61966-2-1, written out in the color spaces article, encodes with a linear segment of slope 12.92 below Y=0.0031308Y = 0.0031308 and with 1.055 Y1/2.4−0.0551.055\,Y^{1/2.4} - 0.055 above it. Its decoding threshold is V=0.04045V = 0.04045. Color Spaces notes that the curve approximates a pure power law with exponent about 2.2. The offset and the linear segment make it depart from that power law near black, as the example measures.

BT.709 and BT.1886

BT.709-6 (item 1.2) specifies the overall opto-electronic transfer characteristic at the source as

V={4.500 L,0≤L<0.018,1.099 L0.45−0.099,0.018≤L≤1,V = \begin{cases} 4.500\,L, & 0 \le L < 0.018, \\ 1.099\,L^{0.45} - 0.099, & 0.018 \le L \le 1, \end{cases}

where LL is the luminance of the image, normalized to [0,1][0, 1], and VV the corresponding electrical signal. Item 3.1 calls this a “conceptual non-linear pre-correction” with γ=0.45\gamma = 0.45. A note to item 1.2 adds that in production practice the source encoding is adjusted until the picture looks right on a reference monitor with the BT.1886 decoding function. In our reading, the OETF is therefore a nominal reference, not a measurement rule. BT.709 also fixes the digital form. Each of R′R', G′G', B′B' and EY′E'_Y is quantized as INT⁡[(219 E′+16)⋅2n−8]\operatorname{INT}[(219\,E' + 16) \cdot 2^{n-8}] (item 3.4; the colour-difference signals use 224 and 128), so 8-bit black and nominal peak sit at codes 16 and 235 (item 4.6). BT.709 calls EY′E'_Y, its weighted sum of nonlinear R′R', G′G' and B′B', the “luminance signal”. The color spaces article and Poynton (1998, §3, §11) call this quantity luma and keep “luminance” for the linear-light quantity.

For decades no standard defined the display side. BT.1886 (Appendix 2) states that the CRT’s EOTF was never documented, because all CRTs behaved similarly, and that a reference became necessary once other display technologies replaced CRTs. Its reference EOTF (Annex 1) is

L=a (max⁡[V+b, 0])γ,γ=2.40,L = a\,\big(\max[V + b,\, 0]\big)^{\gamma}, \qquad \gamma = 2.40, a=(LW1/γ−LB1/γ)γ,b=LB1/γLW1/γ−LB1/γ,a = \big(L_W^{1/\gamma} - L_B^{1/\gamma}\big)^{\gamma}, \qquad b = \frac{L_B^{1/\gamma}}{L_W^{1/\gamma} - L_B^{1/\gamma}},

where LL is screen luminance in cd/m², LWL_W and LBL_B are the screen luminances for white and black, and VV is the normalized signal. For BT.709 10-bit content, V=(D−64)/876V = (D - 64)/876 for code DD. The constants make V=0V = 0 give LBL_B and V=1V = 1 give LWL_W. In the standard’s terms, aa is the legacy “contrast” control (user gain) and bb the legacy “brightness” control (black-level lift). A footnote states that γ=2.40\gamma = 2.40 has been shown to be a satisfactory match to the legacy CRT.

Lightness

To judge how visible a code step is, the example uses CIE lightness L∗L^*. Poynton (1998, §4) describes L∗L^* as a modified cube root of relative luminance, with a linear segment of slope 903.3 near black (below Y/Yn=0.008856Y/Y_n = 0.008856) and a range of 0 to 100. He takes a ΔL∗\Delta L^* of one to be roughly the threshold of visibility. scikit-image computes it with the same threshold and an equivalent slope (116×7.787=903.3116 \times 7.787 = 903.3):

L∗=116 f(Y)−16,f(Y)={Y1/3,Y>0.008856,7.787 Y+16/116,otherwise,L^* = 116\,f(Y) - 16, \qquad f(Y) = \begin{cases} Y^{1/3}, & Y > 0.008856, \\ 7.787\,Y + 16/116, & \text{otherwise}, \end{cases}

for YY relative to white (xyz2lab in scikit-image 0.25.2). The CIE’s own definition is in CIE 015, not read here.

Properties

Codes per stop. A stop is a factor of two in light. For a pure power encoding, the stop from 2−s2^{-s} to 2−(s−1)2^{-(s-1)} spans the signal interval 2−(s−1)/γ (1−2−1/γ)2^{-(s-1)/\gamma}\,(1 - 2^{-1/\gamma}). It follows that each stop down receives a factor 2−1/γ2^{-1/\gamma} of the codes of the stop above: 0.73 for γ=2.2\gamma = 2.2, against 0.5 for linear coding. Linear 8-bit coding spends 128 of its 256 codes on the brightest stop and leaves a single code for the eighth.

Step size per code. Between codes kk and k+1k + 1, the relative change in light is 1/k1/k for linear coding and (1+1/k)γ−1≈γ/k(1 + 1/k)^{\gamma} - 1 \approx \gamma / k for a pure power law. The power law’s relative step is larger at a given code, but the codes sit much lower in light. Over the cube-root part of L∗L^*, dL∗/dY=1163 Y−2/3dL^*/dY = \tfrac{116}{3}\,Y^{-2/3}. So a linear 8-bit step of 1/2551/255 is worth about 0.152 Y−2/30.152\,Y^{-2/3} in L∗L^*, which exceeds one below Y≈0.059Y \approx 0.059, about code 15.

How many bits. Poynton (1998, §13) works the question with the 1% criterion over a 100:1 range of intensities. Linear coding must keep a step of 0.01 all the way up from 1 to 100, which takes about 9,900 codes, or about 14 bits. Coding in which each step is a constant ratio takes about 460 codes, or about 9 bits. He adds that 8 bits coded nonlinearly according to Rec. 709 suffice for broadcast-quality television at a contrast ratio of about 50:1, and that in an 8-bit linear system the 1% limit takes effect below code 100. The example below counts these steps under both visibility models.

End-to-end response. The display’s EOTF is not meant to be the exact inverse of the camera’s OETF. Poynton (1998, §7) explains that pictures reproduced at their physical intensity in a dim surround look lacking in contrast. An end-to-end power of about 1.1 or 1.2 compensates, and video builds this in by under-correcting at the camera, with an exponent of about 1/2.2 against the CRT’s 2.5. With BT.709 into BT.1886 the nominal exponents multiply to 0.45×2.4=1.080.45 \times 2.4 = 1.08. The example shows that the BT.709 offset makes the effective exponent larger than that.

Linear and nonlinear processing. Poynton (1998, §19) separates the two domains by purpose. Simulating physics, such as a lens, needs linear-light values, so camera gamma must first be removed. A computation that should minimize the visibility of errors, such as the DCT quantization in JPEG, belongs in a perceptually uniform, nonlinear coding; he notes that applying JPEG or MPEG to linear-light data gives unacceptable results. Color Spaces makes the same point for averaging, blurring and resizing.

Exactly once. If an image originates in linear light, gamma correction must be applied exactly once (Poynton, 1998, §9). Without it the midtones are reproduced too dark, and applied twice they are too light. In code, the same error appears as decoding an image that is already linear, or encoding one twice before saving (our illustration).

Examples

What a code step is worth

The example measures how four encodings spend their codes:

  • linear 8-bit;
  • sRGB 8-bit, with the constants Color Spaces gives;
  • a pure power law with γ=2.2\gamma = 2.2 in 8 bits;
  • linear 12-bit.

Every encoding is applied as storage would apply it: encode, round to the nearest integer code, decode. Visibility is judged by the two thresholds Poynton gives: a step of one in L∗L^*, and a luminance ratio of 1%. The sources do not settle which one applies, so both are printed. L∗L^* is taken relative to display white at Y=1Y = 1, with no black level or flare.

The script has four parts:

  1. Every code step. It counts the steps that exceed each threshold and prints, at six luminances, the width of the code interval that contains them.
  2. Codes per stop. It counts the codes in each of the ten stops below white.
  3. Two ramps. It stores and decodes two synthetic grey images whose linear luminance is known exactly: a dark ramp from Y=0Y = 0 to 0.05 (64 × 1024 pixels) and a full ramp from 0 to 1 (64 × 4001 pixels). The full ramp’s width was chosen at planning so that its samples do not coincide with the 12-bit codes, which would have made the 12-bit round trip exact. On each image it measures the number of distinct codes, the largest L∗L^* jump between neighbouring pixels (banding), and the largest and RMS L∗L^* error against the true ramp.
  4. Camera into display. It passes scene luminances through the BT.709 OETF and the BT.1886 EOTF with LW=100L_W = 100 cd/m², and LB=0L_B = 0 or 0.10.1 cd/m². BT.1886’s Appendix 1 names 0.1 cd/m² as a moderate black level. It reports the effective end-to-end exponent ln⁡(display)/ln⁡(L)\ln(\text{display}) / \ln(L).

The checks come first:

  • the ramps are strictly increasing, with identical rows and the stated ends;
  • the L∗L^* function equals scikit-image’s xyz2lab on greys;
  • every encoding round-trips without rounding to within 10−1210^{-12};
  • no code is more than half a step from its exact value;
  • the BT.1886 EOTF reaches LBL_B and LWL_W at its ends.

A further printed check (the last column of the ramp table) confirms a bound that follows from rounding. A value between codes kk and k+1k + 1 is stored as one of them, so its L∗L^* error cannot exceed that step. The script uses NumPy 2.3.5 and scikit-image 0.25.2 and runs in well under a second. Two runs printed identical output.

import os
os.environ["OMP_NUM_THREADS"] = "1"          # one BLAS thread, set before NumPy loads
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
import numpy as np
from skimage.color import xyz2lab

# --- Transfer functions: relative luminance Y in [0, 1] <-> signal V in [0, 1] ---
def srgb_encode(Y):
    return np.where(Y <= 0.0031308, 12.92 * Y, 1.055 * Y ** (1 / 2.4) - 0.055)

def srgb_decode(V):
    return np.where(V <= 0.04045, V / 12.92, ((V + 0.055) / 1.055) ** 2.4)

ENCODINGS = {                     # name: (encode, decode, bits)
    "linear 8-bit":  (lambda Y: Y, lambda V: V, 8),
    "sRGB 8-bit":    (srgb_encode, srgb_decode, 8),
    "power 2.2 8-bit": (lambda Y: Y ** (1 / 2.2), lambda V: V ** 2.2, 8),
    "linear 12-bit": (lambda Y: Y, lambda V: V, 12),
}

def lstar(Y):
    """CIE L* of relative luminance Y (white = 1), constants as in skimage.color.xyz2lab."""
    Y = np.asarray(Y, dtype=np.float64)
    f = np.where(Y > 0.008856, np.cbrt(Y), 7.787 * Y + 16 / 116)
    return 116 * f - 16

def quantize(Y, enc, dec, bits):
    """Encode, round to the nearest integer code, decode: what storing the value does."""
    top = 2 ** bits - 1
    code = np.round(enc(Y) * top)
    return dec(code / top), code

# --- Synthetic images with known linear luminance: 64-row horizontal ramps ---
dark_ramp = np.tile(np.linspace(0.0, 0.05, 1024), (64, 1))    # black to Y = 0.05 (L* 26.7)
full_ramp = np.tile(np.linspace(0.0, 1.0, 4001), (64, 1))     # black to white

# --- Checks on exact data ---
print("checks")
print(f"  ramps: monotone {bool(np.all(np.diff(dark_ramp[0]) > 0) and np.all(np.diff(full_ramp[0]) > 0))}, "
      f"rows identical {bool(np.all(dark_ramp == dark_ramp[0]) and np.all(full_ramp == full_ramp[0]))}, "
      f"ends {dark_ramp.min():.2f}-{dark_ramp.max():.2f} and {full_ramp.min():.2f}-{full_ramp.max():.2f}")
grey_xyz = full_ramp[0][:, None] * np.array([0.95047, 1.0, 1.08883])   # greys under D65
print(f"  max |L* - skimage xyz2lab L*| on greys: {np.abs(lstar(full_ramp[0]) - xyz2lab(grey_xyz)[:, 0]).max():.1e}")
for name, (enc, dec, bits) in ENCODINGS.items():
    Y = full_ramp[0]
    exact = np.abs(dec(enc(Y)) - Y).max()                     # no rounding
    v = enc(Y) * (2 ** bits - 1)
    _, code = quantize(Y, enc, dec, bits)
    print(f"  {name:16s} unrounded round trip max |dY| {exact:.1e}; "
          f"max |code - exact| {np.abs(code - v).max():.4f}")

# --- Part 1: what one code step is worth, across the whole range ---
print("\nper-code steps (all codes)")
print(f"  {'encoding':16s} {'max dL*':>8s} {'at Y':>8s} {'steps dL*>1':>12s} {'steps dY/Y>1%':>14s} {'of':>5s}")
steps = {}
for name, (enc, dec, bits) in ENCODINGS.items():
    top = 2 ** bits - 1
    Yk = dec(np.arange(top + 1) / top)                        # luminance of every code
    dL = np.diff(lstar(Yk))
    weber = np.diff(Yk)[1:] / Yk[1:-1]                        # relative step above code k >= 1
    steps[name] = (Yk, dL)
    i = int(np.argmax(dL))
    print(f"  {name:16s} {dL.max():8.3f} {Yk[i]:8.5f} {int((dL > 1).sum()):12d} "
          f"{int((np.round(weber, 12) > 0.01).sum()):14d} {weber.size:5d}")

print("\nthe code step containing a given luminance y: (Y[k+1] - Y[k]) / y in % | dL*")
levels = [0.001, 0.01, 0.05, 0.18, 0.5, 0.9]
print(f"  {'encoding':16s}" + "".join(f"{'Y=' + str(y):>17s}" for y in levels))
for name, (Yk, dL) in steps.items():
    cells = []
    for y in levels:
        k = int(np.searchsorted(Yk, y, side="right") - 1)     # code just at or below y
        cells.append(f"{100 * (Yk[k + 1] - Yk[k]) / y:8.2f} | {dL[k]:5.3f}")
    print(f"  {name:16s}" + "".join(f"{c:>17s}" for c in cells))

# --- Part 2: codes spent in each stop (factor of two in luminance) below white ---
print("\ncodes per stop below white (stop s holds 2^-s <= Y < 2^-(s-1); stop 1 includes white)")
print(f"  {'encoding':16s}" + "".join(f"{s:>6d}" for s in range(1, 11)) + f"{'below':>7s}")
for name, (Yk, _) in steps.items():
    pos = Yk[Yk > 0]
    s = np.floor(-np.log2(pos)).astype(int) + 1               # Y = 1 falls in stop 1
    counts = [int((s == j).sum()) for j in range(1, 11)]
    below = int((s > 10).sum())
    print(f"  {name:16s}" + "".join(f"{c:6d}" for c in counts) + f"{below:7d}")

# --- Part 3: store the two ramp images and measure banding and round-trip error ---
print("\nramp images, stored and decoded: L* error against the true ramp")
print(f"  {'image':5s} {'encoding':16s} {'levels':>7s} {'max jump dL*':>13s} {'max |dL*|':>10s} {'rms dL*':>8s} {'<= step':>8s}")
for label, img in (("dark", dark_ramp), ("full", full_ramp)):
    for name, (enc, dec, bits) in ENCODINGS.items():
        out, code = quantize(img, enc, dec, bits)
        L_true, L_out = lstar(img), lstar(out)
        jump = np.abs(np.diff(L_out, axis=1)).max()           # largest step between neighbours
        err = L_out - L_true
        bound = steps[name][1][: int(code.max()) + 1].max()   # largest step among the codes used
        print(f"  {label:5s} {name:16s} {np.unique(code).size:7d} {jump:13.3f} "
              f"{np.abs(err).max():10.3f} {np.sqrt((err ** 2).mean()):8.3f} "
              f"{str(bool(np.abs(err).max() <= bound + 1e-9)):>8s}")

# --- Part 4: camera curve into display curve (BT.709 OETF, then BT.1886 EOTF) ---
def bt709_oetf(L):
    return np.where(L < 0.018, 4.5 * L, 1.099 * L ** 0.45 - 0.099)

def bt1886_eotf(V, Lw=100.0, Lb=0.0, g=2.4):
    a = (Lw ** (1 / g) - Lb ** (1 / g)) ** g
    b = Lb ** (1 / g) / (Lw ** (1 / g) - Lb ** (1 / g))
    return a * np.maximum(V + b, 0) ** g

print("\nscene luminance -> BT.709 signal -> BT.1886 display (Lw = 100 cd/m2), relative to white")
ends = [bt1886_eotf(0.0) - 0.0, bt1886_eotf(1.0) - 100.0,
        bt1886_eotf(0.0, Lb=0.1) - 0.1, bt1886_eotf(1.0, Lb=0.1) - 100.0]
print(f"  EOTF(0) - Lb and EOTF(1) - Lw, cd/m2: largest |error| {max(abs(e) for e in ends):.1e}")
print(f"  {'scene':>7s} {'signal':>7s} {'Lb=0':>8s} {'exponent':>9s} {'Lb=0.1':>8s} {'exponent':>9s}")
for L in (0.01, 0.05, 0.18, 0.5, 0.9):
    V = bt709_oetf(L)
    d0, d1 = bt1886_eotf(V) / 100, bt1886_eotf(V, Lb=0.1) / 100
    print(f"  {L:7.2f} {V:7.4f} {d0:8.5f} {np.log(d0) / np.log(L):9.3f} {d1:8.5f} {np.log(d1) / np.log(L):9.3f}")

Output:

checks
  ramps: monotone True, rows identical True, ends 0.00-0.05 and 0.00-1.00
  max |L* - skimage xyz2lab L*| on greys: 0.0e+00
  linear 8-bit     unrounded round trip max |dY| 0.0e+00; max |code - exact| 0.5000
  sRGB 8-bit       unrounded round trip max |dY| 4.4e-16; max |code - exact| 0.4998
  power 2.2 8-bit  unrounded round trip max |dY| 1.1e-16; max |code - exact| 0.4997
  linear 12-bit    unrounded round trip max |dY| 0.0e+00; max |code - exact| 0.5000

per-code steps (all codes)
  encoding          max dL*     at Y  steps dL*>1  steps dY/Y>1%    of
  linear 8-bit        3.542  0.00000           15             99   254
  sRGB 8-bit          0.509  0.00913            0            226   254
  power 2.2 8-bit     0.588  0.00902            0            220   254
  linear 12-bit       0.221  0.00269            0             99  4094

the code step containing a given luminance y: (Y[k+1] - Y[k]) / y in % | dL*
  encoding                  Y=0.001           Y=0.01           Y=0.05           Y=0.18            Y=0.5            Y=0.9
  linear 8-bit       392.16 | 3.542    39.22 | 3.298     7.84 | 1.132     2.18 | 0.478     0.78 | 0.241     0.44 | 0.163
  sRGB 8-bit          30.35 | 0.274     6.09 | 0.506     3.13 | 0.442     1.82 | 0.398     1.19 | 0.365     0.93 | 0.348
  power 2.2 8-bit     20.94 | 0.189     7.01 | 0.583     3.38 | 0.479     1.87 | 0.411     1.19 | 0.363     0.91 | 0.338
  linear 12-bit       24.42 | 0.221     2.44 | 0.205     0.49 | 0.070     0.14 | 0.030     0.05 | 0.015     0.03 | 0.010

codes per stop below white (stop s holds 2^-s <= Y < 2^-(s-1); stop 1 includes white)
  encoding             1     2     3     4     5     6     7     8     9    10  below
  linear 8-bit       128    64    32    16     8     4     2     1     0     0      0
  sRGB 8-bit          68    51    37    29    21    16    12     9     6     3      3
  power 2.2 8-bit     69    51    36    27    20    14    10     8     6     4     10
  linear 12-bit     2048  1024   512   256   128    64    32    16     8     4      3

ramp images, stored and decoded: L* error against the true ramp
  image encoding          levels  max jump dL*  max |dL*|  rms dL*  <= step
  dark  linear 8-bit          14         3.542      1.766    0.656     True
  dark  sRGB 8-bit            64         0.509      0.251    0.130     True
  dark  power 2.2 8-bit       64         0.588      0.290    0.144     True
  dark  linear 12-bit        206         0.221      0.094    0.041     True
  full  linear 8-bit         256         3.542      1.736    0.177     True
  full  sRGB 8-bit           256         0.509      0.238    0.108     True
  full  power 2.2 8-bit      249         0.588      0.287    0.110     True
  full  linear 12-bit       4001         0.441      0.110    0.011     True

scene luminance -> BT.709 signal -> BT.1886 display (Lw = 100 cd/m2), relative to white
  EOTF(0) - Lb and EOTF(1) - Lw, cd/m2: largest |error| 1.4e-14
    scene  signal     Lb=0  exponent   Lb=0.1  exponent
     0.01  0.0450  0.00059     1.616  0.00386     1.207
     0.05  0.1865  0.01776     1.346  0.03007     1.170
     0.18  0.4090  0.11699     1.251  0.14112     1.142
     0.50  0.7055  0.43293     1.208  0.45772     1.127
     0.90  0.9491  0.88218     1.190  0.88858     1.121

Checks. All the checks passed:

  • the ramps have the planned properties;
  • the L∗L^* function reproduces scikit-image exactly;
  • every encoding round-trips to within 4.4×10−164.4 \times 10^{-16} without rounding;
  • the BT.1886 constants reach LBL_B and LWL_W to within 1.4×10−141.4 \times 10^{-14} cd/m²;
  • the bound column is True in all eight rows.

The half-step check prints 0.5000 for the linear encodings because some ramp samples fall exactly halfway between codes; the sample Y=0.5Y = 0.5, for instance, is code 127.5 in 8 bits and 2047.5 in 12 bits. That is still within the allowed half step, and NumPy rounds such ties to the even code.

The eight-bit bottleneck. The plan, written before the example was first run, predicted from the step formulas that linear 8-bit coding has about 15 steps above ΔL∗=1\Delta L^* = 1. The largest would be 116×7.787/255=3.542116 \times 7.787 / 255 = 3.542, from black to code 1. The other three encodings would have none: sRGB peaks at about 0.51 and the power law at about 0.59, both in the step just above the point where L∗L^* changes from its linear segment to its cube root, and 12-bit linear stays at 0.221 through the linear segment. All of this held. This is Poynton’s “eight-bit bottleneck” (1998, §14): linear light in 8 bits bands near black. On the dark ramp it shows as 14 grey levels with jumps of 3.5 L∗L^*, against 64 levels with jumps of about half an L∗L^* unit for the two gamma encodings and 206 levels with jumps of 0.221 for 12-bit linear. The planned levels and jumps on the dark ramp held for all four encodings. The power law also uses 64 levels, not the 66 its range spans (the ramp’s top, 0.051/2.2×255=65.30.05^{1/2.2} \times 255 = 65.3, rounds to code 65). Near black its codes are narrower than the spacing between ramp samples, so a few are skipped, as the plan derived. The round-trip predictions held on both ramps. Linear 8-bit errs by up to 1.77 L∗L^* (1.766 dark, 1.736 full), between the planned 1.5 and L∗(0.5/255)=1.771L^*(0.5/255) = 1.771. sRGB errs by at most 0.251 and the power law by at most 0.290, both under the planned 0.5. 12-bit linear stays at or below 0.110, under the planned L∗(0.5/4095)=0.1103L^*(0.5/4095) = 0.1103 plus 10−310^{-3} and below sRGB on both ramps. By the L∗L^* model, four extra bits of linear coding do better than 8-bit gamma encoding.

The 1% model gives a different verdict. By the 1% criterion, every encoding has visible steps. The planned counts held for all four encodings: linear coding exceeds 1% at codes 1 to 99, exactly as Poynton (§13) states for codes below 100. At code 100 the step is exactly 1%, and the code rounds before comparing so that this tie does not count. 12-bit linear has the same 99 steps, because its step relative to the value is again 1/k1/k. sRGB exceeds 1% at 226 of 254 steps and the power law at 220. This is consistent with Poynton’s estimate that about 9 bits are needed for 1% steps over 100:1, and his remark that 8-bit Rec. 709 coding suffices at about 50:1. The table at fixed luminances shows why the two models disagree: at Y=0.18Y = 0.18 an sRGB step of 1.82% is worth only 0.398 L∗L^*. Over this range L∗L^* tolerates a few percent of luminance change per unit. Which threshold applies depends on viewing conditions that the code does not model.

Codes per stop. Linear 8-bit halves its codes in every stop: 128 in the top stop, one in the eighth and none below. The power law falls by roughly the predicted factor of 0.73 to 0.75 per stop, within the rounding of small counts, and sRGB does so down to stop 8. Below that sRGB’s count falls faster (9, 6, 3), towards the halving of linear coding, because its linear segment begins at Y=0.0031308Y = 0.0031308, inside stop 9. The pure power law keeps 10 codes below 2−102^{-10} where sRGB keeps 3, because sRGB’s linear segment caps the slope near black. That segment is also why sRGB’s steps at Y=0.001Y = 0.001 are coarser in relative terms than the power law’s (30.35% against 20.94%). The plan predicted every count with a separate counting formula and set the criterion that the example reproduce each one exactly. That check failed in one cell: for sRGB’s top stop the example prints 68 and the planning formula gave 67. The example’s count is the correct one. sRGB encodes Y=0.5Y = 0.5 as 1.055×0.51/2.4−0.055=0.73541.055 \times 0.5^{1/2.4} - 0.055 = 0.7354, or 187.5 codes, the value the color spaces example prints for its linear-light average, so the top stop holds codes 188 to 255, which is 68 codes; the planning formula undercounted that stop by one. Neither the planning script nor the example was changed. All other counts matched the formula; the hypothesis failed in that one cell only.

Full-ramp details. On the full ramp, 12-bit linear’s largest jump is 0.441, twice its step. The ramp samples are 1/40001/4000 apart, slightly more than the 12-bit code width of 1/40951/4095, so some codes are skipped and neighbouring pixels can differ by two codes. The power law’s 249 levels have the same cause near black. RMS errors are smaller than the maxima by factors of about two to ten; in our reading this is because the largest steps are confined to a narrow dark range.

Camera into display. With a black display, the BT.709 signal shown through BT.1886 gives an effective end-to-end exponent of 1.251 at mid grey and 1.208 at half white. The plan predicted both within 1.15–1.30 from the formulas, and they are. The exponent rises to 1.616 at 1% of white. Every printed value exceeds the product 0.45×2.4=1.080.45 \times 2.4 = 1.08, because the OETF’s offset of 0.099 (and, at L=0.01L = 0.01, its linear segment) puts the signal below the pure L0.45L^{0.45} power. Raising the display black to 0.1 cd/m² lowers every exponent, to between 1.121 and 1.207, and the exponent falls with luminance in both columns. All four planned predictions for this part held. The midtone values are close to the 1.1 to 1.2 that Poynton (§7) gives for dim-surround viewing. The camera and display curves are deliberately not inverses, so “undoing gamma” with the display curve does not recover scene-linear light (our reading of the two standards).

Common Misconceptions

  • “Gamma correction is one operation.” The name covers three things: a camera’s encoding (OETF), a display’s response (EOTF), and an arbitrary power-law tone adjustment such as scikit-image’s adjust_gamma. The first two are fixed by standards and come as a pair; the third is a point operation chosen for appearance.
  • “sRGB is a 2.2 power law.” It approximates one over most of the range. Near black its linear segment allocates codes differently: 3 codes below 2−102^{-10} against the power law’s 10, and coarser relative steps at Y=0.001Y = 0.001, as the example shows.
  • “Decoding with the display curve gives scene light.” BT.709’s OETF and BT.1886’s EOTF combine to an end-to-end exponent of about 1.2 to 1.25 in the midtones with a black display, and more in the shadows. BT.709’s note on production practice also means the encoding of real footage is adjusted by eye, so, in our reading, its signal is not a calibrated measure of scene luminance. Methods that need scene-linear values need a calibrated camera response, as in HDR imaging.
  • “Eight bits are always enough” or “never enough.” The answer depends on the visibility model and the contrast range. By ΔL∗=1\Delta L^* = 1, 8-bit gamma encodings have no visible steps on a ramp; by a 1% ratio over 100:1, they need about one more bit (Poynton, 1998, §13). Linear 8-bit fails both.
  • “Values are linear unless stated.” Poynton (1998, §1) warns that image data in a file may or may not represent intensity, even if it is described as doing so. The digital camera article separates RAW values, close to proportional to light after black-level subtraction, from processed images that have passed through the full pipeline.
  • “Luma is luminance.” BT.709 calls its weighted sum of encoded components the “luminance signal”, while Poynton and the color spaces article call it luma and reserve luminance for linear light. The source’s own term should be read with that in mind.

Where It Is Used

Every stored 8-bit image passes through a transfer function. The digital camera and image formation articles place it near the end of the image signal processor. Color spaces defines the sRGB curve and explains why averaging, blurring and resizing belong in linear light, and it notes that downsampling should respect linearity as well as sampling and aliasing.

Methods that model light undo the transfer function first. Photometric stereo and HDR imaging need values proportional to light, and HDR imaging estimates the camera response when it is not known; direct sparse odometry works on images corrected with a photometric calibration that includes the camera’s response. Noise statistics differ between linear raw data and encoded images, as image noise discusses.

Feature pipelines treat gamma either as a preprocessing choice or as a nuisance. Histogram of oriented gradients offers square-root gamma compression of each channel. Local binary patterns are unchanged by any increasing grey-level map, a gamma curve included, when their neighbours lie on pixel centres; with interpolated neighbours they are only approximately invariant, which that article measures. Super-resolution papers often compute PSNR on luma, which is built from encoded values; in our reading their errors are then weighted roughly by perceived lightness rather than by light. Display and tone reproduction close the loop: the image processing overview lists gamma encoding among display operations, and HDR imaging’s tone curves map scene radiance into the range an encoded display signal can carry.

Related

  • Point Operations

    Operations that compute each output pixel from the input value at the same pixel alone, such as negatives, log and power-law maps, contrast stretches, windows and thresholds; why every one is a look-up table, and what an 8-bit table merges, skips and cannot invert.

  • Digital Camera

    The camera as an imaging system, from lens, shutter and sensor to the image signal processor that demosaics, white-balances, colour-corrects, tone-maps and compresses its output; RAW against processed images, metadata, machine-vision interfaces, camera classes, and where processing breaks the assumptions of vision methods.

  • Image Formation

    How light leaving a scene becomes an array of pixel values, through illumination and reflectance, the lens, the sensor and its colour filters, sampling and quantization, and the camera's own processing.

  • High Dynamic Range Imaging

    Capturing scenes whose brightness range exceeds what one exposure can record, by bracketing exposures, recovering the camera response and a radiance map, merging with noise-aware weights and removing ghosts, or with single-shot HDR sensors and learned single-image reconstruction, and compressing the result for display by tone mapping or exposure fusion.

  • Image Processing

    Computing new images, or simple measurements, from images by point, neighborhood, global and geometric operations; how enhancement, restoration and learned methods build on them, and where image processing meets computer vision.

  • Photometric Stereo

    Recovering surface orientation and albedo at every pixel from several images taken from a fixed viewpoint under different lighting, from Woodham's Lambertian least squares to uncalibrated, robust and learned methods and the DiLiGenT benchmark.

References

  1. Poynton, C. (1998). Frequently Asked Questions about Gamma. Document dated 1998-01-04a.
  2. ITU-R (2015). Recommendation ITU-R BT.709-6: Parameter Values for the HDTV Standards for Production and International Programme Exchange.
  3. ITU-R (2011). Recommendation ITU-R BT.1886: Reference Electro-Optical Transfer Function for Flat Panel Displays Used in HDTV Studio Production.
  4. International Electrotechnical Commission (1999). IEC 61966-2-1:1999. Multimedia Systems and Equipment — Colour Measurement and Management — Part 2-1: Colour Management — Default RGB Colour Space — sRGB (not read).
  5. CIE (2018). CIE 015:2018 Colorimetry (4th ed.). International Commission on Illumination, Vienna (not read).
  6. scikit-image developers (2025). skimage.color.xyz2lab and rgb2xyz, skimage/color/colorconv.py, scikit-image v0.25.2 source code.
  7. scikit-image developers (2025). skimage.exposure.adjust_gamma, skimage/exposure/exposure.py, scikit-image v0.25.2 source code.