happyin.work / blog

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

Exposición y balance de blancos: tres multiplicadores en luz lineal

Autora: Anastasiia Butova, ingeniera de ComfyUI y modelos de difusión, Belgrado · publicado 2026-09-23

Traducción de la versión en inglés: English

En el estudio de color que estoy construyendo, la exposición y el balance de blancos son una sola operación: tres multiplicadores sobre RGB lineal. Todo lo interesante está alrededor de esa multiplicación. Qué valores cuentan como lineales, a qué se normalizan las ganancias para que un gris conserve su brillo, y qué hace el motor cuando un canal supera 1. Esta es la primera parte de una serie sobre cómo se implementan las herramientas básicas de color, con las matemáticas, el C++ y los errores que detectaron las pruebas.

El motor y el espacio en el que trabaja

El estudio es un editor local construido sobre LUT Atelier, un editor de color que escribí primero en mi centro de investigación y que después fijé en el estudio mediante el SHA-256 de cada archivo fuente. Varias correcciones descritas más abajo se incorporaron al motor después de fijarlo. El lado nativo es C++20 y solo usa la biblioteca estándar; el único archivo de terceros es nlohmann/json 3.12.0, incluido para el protocolo de línea de comandos. La copia en JavaScript de las matemáticas sigue viva como oráculo independiente: una ejecución con semilla sobre 600 etalonajes aleatorios compara 196.200 valores de salida entre ambos motores, y la mayor diferencia es 8,7e-14.

La entrada es honesta sobre lo que es. Los JPEG, WebP y PNG de 8 bits llegan a través del canvas del navegador como sRGB de 8 bits. Una ventana de laboratorio aparte lee PNG de 16 bits sin dejar que el canvas los redondee. No hay decodificador RAW, ni HDR, ni perfil ICC distinto de sRGB. La transformación de color calcula en double y solo redondea al escribir la salida.

Los valores sRGB están codificados, no son lineales. El motor decodifica cada canal con la curva IEC 61966-2-1 antes de cualquier multiplicación:

c_lin = c / 12.92                      if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4      otherwise

Un gris medio de 0,5 se decodifica a 0,214. Ese número es la razón de la mayor parte de este artículo.

El orden de un gradado dentro del motor, desde native/src/color.cpp:

  1. Etapa de entrada: Niveles de checker o un LUT importado, mezclados con un único control de intensidad.
  2. Decodificar a lineal y luego multiplicar por las ganancias de balance de blancos, exposición y temperatura/tinte.
  3. Ediciones Oklab opcionales (matiz, saturación, pines de color, ajuste de referencia), y luego de vuelta a RGB codificado.
  4. Contraste, curva maestra, curvas R/G/B.
  5. Etapa fotográfica: Niveles, curva de luma, controles de tono, vibrance, ruedas de gradado, color selectivo.
  6. Mezclador de canales, protección de color, intensidad global.

La exposición es una multiplicación, así que vive en luz lineal

La exposición es 2^EV aplicado a RGB lineal. +1 EV duplica la luz, −1 la reduce a la mitad, y el control va de −3 a +3. La razón por la que tiene que hacerse con valores lineales es física: el doble de luz es el doble de señal lineal. No es el doble del valor de código codificado.

Rampa de grises a +1 EV: multiplicar RGB lineal recorta desde el código de entrada 188, multiplicar valores codificados recorta desde el 128

La figura está muestreada del propio motor. A +1 EV, el código de entrada 128 sale en 175,6, y el blanco se alcanza desde el código de entrada 188. Si en cambio multiplicas por dos el valor codificado, todo lo que va desde el código 128 hacia arriba se vuelve blanco y se pierde la mitad superior de la rampa. Adobe documenta el ajuste de Exposición de Photoshop como un cálculo en el espacio de color lineal, y Lightroom describe +1,00 como aproximadamente un paso de diafragma. No afirmo usar su fórmula; la multiplicación es simplemente lo que significa la palabra.

Aquí está el camino completo en C++. La exposición y la temperatura/tinte se combinan en un único vector de ganancia una vez por gradado, y el bucle de píxeles hace tres multiplicaciones:

// native/src/color.cpp
Vec3 linearGains(const Grade& grade) {
  const Vec3 warm{1. + grade.temperature * .4, 1. + grade.tint * .25, 1. - grade.temperature * .4};
  const double exposure = std::exp2(grade.exposure), y = linearLuminance(warm);
  return {exposure * warm[0] / y, exposure * warm[1] / y, exposure * warm[2] / y};
}

Vec3 workLinear(Vec3 original, const Grade& grade, const std::optional<Cube>& lut, Vec3 gains) {
  // ... input stage: checker Levels, then the imported LUT, blended by importedStrength ...
  return {srgbToLinear(original[0]) * grade.wb[0] * gains[0],
          srgbToLinear(original[1]) * grade.wb[1] * gains[1],
          srgbToLinear(original[2]) * grade.wb[2] * gains[2]};
}

Los invariantes documentados son sencillos. EV 0 es un multiplicador de exactamente 1, y la suite matemática comprueba que +1 EV duplica el RGB lineal con error cero. +1 seguido de −1 devuelve la entrada allí donde nada se recortó: en la rampa de grises medí un error de ida y vuelta de 4e-14 de código.

Balance de blancos: la misma multiplicación, canal a canal

El balance de blancos aquí es un escalado diagonal del RGB lineal, el modelo de von Kries: una ganancia por canal. Las ganancias vienen de dos sitios. El cuentagotas neutro escribe wb, y los controles de temperatura y tinte producen multiplicadores relativos:

w = (1 + 0.4·T,  1 + 0.25·t,  1 − 0.4·T) / Y(w)
Y(w) = 0.2126·w_R + 0.7152·w_G + 0.0722·w_B

La división entre Y(w) es la parte que hice mal al principio. Sin ella, el tinte +1 multiplica el verde por 1,25, y un gris se vuelve más brillante en log2(1,179), unos 0,24 EV; el tinte −1 lo oscurece 0,28 EV. Así que un control que se supone que cambia el color también estaba cambiando la exposición. Con la división, la temperatura y el tinte recolorean un neutro sin cambiar su luminancia lineal. Pasé un gris medio por tinte +1: sale como (118,1, 131,0, 118,1), y su Y lineal es igual a la de la entrada hasta el último bit.

La brújula que maneja estos controles es una imagen de dos números acotados, no un termómetro. La marca está en x = cx + R·tanh(temperature), y = cy − R·tanh(tint), y arrastrar invierte eso con atanh, limitado a 0,7615, justo por debajo de tanh(1), el extremo del rango del control. No hay Kelvin en ella. Lightroom muestra Kelvin para archivos RAW pero una escala relativa de −100 a +100 para otros archivos; Capture One muestra Kelvin y tinte. Un JPEG casi siempre ya ha sido equilibrado una vez, en la cámara o en el revelado RAW, y sin perfil de cámara no hay nada por lo que convertir Kelvin. Una escala relativa es la honesta.

El cuentagotas neutro conserva el brillo de lo que pinchas

Haz clic en un parche gris, y el motor lo decodifica a lineal (r, g, b) y establece

g_c = Y / c,   Y = 0.2126·r + 0.7152·g + 0.0722·b

Tras la multiplicación, los tres canales son iguales a Y, así que el punto pinchado se convierte en un gris de su propia luminancia. La versión que fijé normalizaba a la media geométrica (r·g·b)^(1/3). Eso mantiene el producto de las ganancias en 1 y suena elegante, pero el punto pinchado se volvía más brillante o más oscuro: 0,30 EV para la muestra de prueba (0,6, 0,45, 0,3), y unos 1,06 EV para una muestra verdosa como (0,3, 0,6, 0,3). Una herramienta de color estaba moviendo la exposición en silencio otra vez. La versión con luminancia se publicó el 22 de septiembre, y los motores JS y C++ coinciden bit a bit en el conjunto de comprobación.

// native/src/color.cpp
Vec3 neutralGains(Vec3 rgb) {
  if (!finite3(rgb) || rgb[0] <= 0. || rgb[1] <= 0. || rgb[2] <= 0. || rgb[0] > 1. || rgb[1] > 1. || rgb[2] > 1.) throw std::range_error("neutralGains requires finite encoded sRGB channels in (0, 1]");
  const Vec3 linear{srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])};
  // g_i = Y / c_i: the pick becomes a grey of its own luminance (see color-core.mjs neutralGains).
  const double y = linearLuminance(linear);
  Vec3 gains{y / linear[0], y / linear[1], y / linear[2]};
  for (double gain : gains) if (!std::isfinite(gain) || gain > 8.) throw std::range_error("neutralGains pick needs a gain above grade.wb limit 8: it is too dark or too far from neutral");
  return gains;
}

Una selección correcta además pone temperatura y tinte de nuevo a cero, así que las ganancias en pantalla son el balance completo. Una selección que necesitaría una ganancia superior a 8 se rechaza con ese mensaje, y una selección con un canal a cero con un mensaje propio. Prefiero un rechazo a una ganancia recortada, porque una ganancia recortada produce un resultado que parece que la herramienta funcionó cuando no lo hizo. El caso de prueba de la suite es una muestra muy tintada que necesita ganancias de (4,40, 1,10, 0,24); tras la multiplicación sus canales son iguales, con una dispersión de exactamente cero.

Dónde haces clic importa más que la fórmula. Una fotografía no puede separar el color de la luz del color del objeto: la señal lineal es I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ, y multiplicar la luz E por cualquier función espectral mientras divides la reflectancia R por esa misma función da el mismo píxel. Por eso el cuentagotas debe usarse sobre una tarjeta gris o sobre una superficie que sabes que es neutra, nunca sobre la piel ni a una camiseta "blanca" de tinte desconocido. Por la misma razón, el motor todavía no tiene balance de blancos automático. Cada método automático descansa en una suposición, y la clásica suposición del "mundo gris" convierte una escena que de verdad es verde en gris; las comprobaciones de investigación conservan ese contraejemplo a propósito.

Lo que está implementado para neutros conocidos es un panel de Comprobación de carta: un parche neutro conocido, su RGB codificado y lineal, las ganancias y el residuo tras ellas. Para el propio parche, el residuo es cero por construcción, así que la comprobación útil son los demás neutros del encuadre, leídos en el parade RGB. Un solucionador para varios parches (la mediana de las razones por parche) está escrito pero no construido. Los parches que no coinciden no se promedian en un balance "ideal" oculto; la discrepancia se mantiene visible en el parade.

Un orden que todavía no he cambiado

En el motor actual, el LUT importado se ejecuta antes del balance de blancos y de la exposición. Para un LUT de look eso va al revés: quieres normalizar primero el encuadre y aplicar el look después, si no el mismo look cae de forma distinta en cada fotograma de una serie. No lo he cambiado, porque cambiarlo en silencio alteraría cada proyecto guardado. Hace falta una receta versionada, donde los proyectos antiguos conserven el orden antiguo y los nuevos reciban el nuevo. Mientras tanto, el cuentagotas neutro lee el píxel después de la etapa de entrada, así que sus ganancias al menos se refieren a lo que de verdad recibe la multiplicación.

Qué pasa por encima de 1,0

El motor es SDR. Con solo exposición y balance de blancos, cada canal se codifica y se recorta a [0, 1] justo después de la multiplicación; con ediciones Oklab activas, el recorte llega después, como muy tarde cuando se escribe el resultado. De esto se siguen dos cosas, y ambas son visibles.

Primero, el detalle recortado no vuelve. Bajar una curva después de la exposición solo oscurece un 255 plano; los valores por encima se tiraron una etapa antes, y la documentación de la herramienta de exposición lo dice.

Segundo, un recorte por canal cambia el tono. Cuando el canal rojo llega a 1 y el verde y el azul siguen creciendo, el color se desliza hacia el amarillo.

Tono de un color cálido con la exposición creciente: el recorte por canal lo vuelve amarillo, la compresión suave de gama lo aclara hacia el blanco

Las cifras son del motor. Un sRGB cálido (204, 133, 87) tiene un tono Oklab de 52,9°. Su canal rojo llega a 255 en +0,73 EV. A +1 EV la salida es (255, 182, 121) y el tono es 60,4°; a +2 EV es (255, 248, 166), tono 103,9°. Un naranja se convirtió en un amarillo pálido.

Hay una alternativa opcional, la compresión suave de gama. En lugar de cortar cada canal, mueve un color fuera de rango hacia el gris de la misma luminosidad Oklab, a lo largo de un segmento recto en sRGB lineal, hasta que cabe en el cubo. Tras una edición creativa Oklab sobre un color que empezó dentro del cubo, al menos el primer 80 % de la distancia al límite se mantiene exacto y solo el resto se comprime con un hombro exponencial. Con solo exposición, el punto de referencia es el propio color expuesto, que ya está fuera, así que allí es una simple proyección sobre la superficie. Sobre el mismo color cálido, +1 EV da (255, 192, 159) con tono 48,5°: el tono se mueve menos, pero la croma cae de 0,115 (el resultado recortado duro; el color de origen tiene 0,107) a 0,085, y desde aproximadamente +1,67 EV, donde la luminosidad Oklab llega a 1, el resultado es blanco puro. Ninguna de las dos opciones es recuperación de altas luces.

La primera versión era la del libro de texto: mantener fijas la luminosidad y el tono Oklab y reducir la croma a lo largo de ese rayo. Falla cerca del azul. En el lado azul, un rayo de luminosidad y tono fijos puede salir de la gama y volver a entrar, y un mapa que debe ser continuo no puede saltar esa brecha. La pequeña reentrada cerca de 264° es una medición mía con las matrices del motor, no de él: el artículo sobre Oklab de Björn Ottosson dice que un primer ajuste plegaba los azules hacia dentro, y que él limitó el Oklab final para que eso no ocurra. El enfoque Oklab de la compresión suave, y su advertencia contra el recorte RGB duro, vienen de sus notas sobre recorte de gama. El segmento en RGB lineal hacia un gris de la misma luminosidad tiene un único cruce porque el cubo RGB es convexo, así que la continuidad sale gratis. Tiene un coste que medí: en una rampa cálida y brillante a +0,8 EV, códigos de entrada vecinos de 8 bits pueden acabar a 19 códigos de salida de distancia. Eso es una respuesta empinada, no un salto; la prueba permite hasta 20 y falla en 21.

Capture One documenta una recuperación de altas luces que puede usar los canales que no se han recortado. Eso funciona sobre datos RAW con margen. Con entrada sRGB de 8 bits no hay nada por encima de 255 que recuperar, así que prefiero mostrar el recorte con honestidad antes que inventar detalle.

Límites, y lo que volvería a hacer

  • SDR sRGB de 8 bits en la ventana principal, PNG de 16 bits solo en la ventana de laboratorio. Sin RAW, sin Kelvin, sin ICC.
  • Balance de blancos desde un único parche conocido; varios parches y la estimación automática están diseñados, no construidos.
  • El LUT importado todavía se ejecuta antes del balance de blancos. La solución necesita una receta versionada.

Lo que haría desde el primer día la próxima vez: dividir temperatura y tinte entre su propia luminancia, mantener una segunda implementación de las matemáticas como oráculo, y dejar que el cuentagotas rechace una muestra con una razón en lugar de recortar sus ganancias.

Siguiente parte: Niveles, histogramas y puntos de negro y blanco por carta, donde el mismo motor trabaja en valores codificados a propósito. El plugin de retoque en C++ en el dispositivo que distribuyo para Photoshop se describe en happyin.ai.

Colour tools, built from scratch

  1. Exposición y balance de blancos: tres multiplicadores en luz lineal
  2. Niveles: puntos de negro y blanco, gamma, canales y una carta
  3. Curvas: spline natural frente a PCHIP, sobreimpulso, luma y horneado en LUT
  4. Tono, saturación y luminosidad por rango de color, calculados en Oklab y no en HSL
  5. El mezclador de canales, una matriz 3×3: qué conserva, dónde recorta y dónde se ejecuta
  6. LUT 3D: hornear un gradado en .cube y Hald, y qué se pierde entre los nodos

Más artículos

En otras partes del sitio

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face