happyin.work / blog

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

Niveles: puntos de negro y blanco, gamma, canales y una carta

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

Niveles es la herramienta tonal con menos misterio: un recorte, una línea recta y una potencia. En mi estudio de color trabaja a propósito sobre valores sRGB codificados, después de la multiplicación en luz lineal de la parte 1. La fórmula ocupa tres líneas. Este artículo trata de todo lo que la rodea: el orden de los conjuntos maestro y de canal, qué mide realmente el histograma, los solucionadores de los cuentagotas, los puntos de negro y blanco tomados de una carta de color y un LUT que costó dieciséis códigos junto al negro.

La fórmula

Para un valor de canal x en 0…1, con negro de entrada b, blanco de entrada w, gamma γ y negro y blanco de salida ob, ow:

u   = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)

b cae en ob, w cae en ow, y todo lo que queda fuera del rango de entrada se recorta. Con b = 0, w = 1, γ = 1, ob = 0, ow = 1 la función es la identidad. El panel muestra los puntos de entrada y salida como 0…255 y la gamma como 0,2…5. El editor mantiene al menos 1/255 entre el negro y el blanco de entrada, y el propio modelo rechaza w − b < 1e-6. El par de salida no tiene esa regla: un negro de salida por encima del blanco de salida es una inversión, y una inversión es algo legítimo que pedir.

El control gris está donde la curva produce el punto medio del rango de salida:

m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5

Así que una gamma por encima de 1 mueve el control hacia el negro y aclara los medios; una gamma por debajo de 1 hace lo contrario.

Curvas de transferencia de Niveles muestreadas del motor: gamma 0,5, 1 y 2 con negro de entrada 0,10 y blanco 0,90; un rango de salida de 40 a 215 y su inversión

Adobe documenta los mismos controles para Niveles de Photoshop, y Capture One va más lejos: su página de Niveles da un ejemplo de remapeo lineal, entrada de 0–220 a salida de 0–244, y describe el control central como gamma. Ninguno publica una implementación, así que no reclamo paridad de bits con ninguno de los dos. Lo que sí afirmo es que la función de arriba es la que ejecutan ambos motores del estudio.

¿Por qué valores codificados y no luz lineal? La exposición y el balance de blancos modelan luz, así que multiplican el RGB lineal. Niveles es un remapeo tonal que la gente maneja mirando un histograma de valores de código, y un control de gamma en luz lineal caería donde nadie lo espera. Por eso el cuentagotas gris de Niveles y el cuentagotas neutro del balance de blancos son operaciones separadas: uno dobla la gamma de cada canal en valores codificados, el otro multiplica canales lineales.

Primero el maestro, luego cada canal

Un etalonaje tiene un conjunto maestro y tres conjuntos de canal. El conjunto maestro se guarda en los mismos campos planos de siempre, así que un proyecto guardado antes de que existieran los conjuntos de canal sigue siendo byte a byte idéntico. Cada canal pasa primero por el maestro y después por su propio conjunto:

// native/src/photo_color.cpp
double applyLevels(const LevelsSet& set, double x) {
  const double normalized = clamp((x - set.blackPoint) / (set.whitePoint - set.blackPoint));
  return set.outputBlack + (set.outputWhite - set.outputBlack) * std::pow(normalized, 1. / set.gamma);
}
// ... inside CompiledPhoto::apply
const LevelsSet master = masterLevels(grade);
for (int c = 0; c < 3; ++c) levelled[c] = applyLevels(grade.channelLevels[c], applyLevels(master, encoded[c]));
// Levels alone never needs Oklab: skipping the round trip keeps exact zeros and ones exact.
if (levelsOnly) return levelled;

y_c = f_c(f_master(x_c)) es una decisión mía para este motor, no un hecho documentado sobre el orden interno de Photoshop.

La diferencia entre los dos tipos de conjunto es la razón de que existan ambos. La misma función en los tres canales mantiene un gris como gris, pero no mantiene la proporción R:G:B de un color, así que la saturación se mueve con el contraste. Un conjunto por canal separa los canales, que es exactamente lo que elimina una dominante, y exactamente lo que añade una si se usa mal.

El comentario de ese fragmento oculta una trampa que solo entendí con números. Dentro de la etapa fotográfica, Niveles se ejecuta antes de cualquier trabajo en Oklab, pero la transformación global de color que venía antes lo enviaba todo a Oklab y de vuelta, y ese viaje de ida y vuelta es la identidad solo en aritmética exacta. La gamma de Niveles amplificaba entonces el residuo cerca de cero: con γ = 5, un canal que debería ser 0 pero vuelve como 1e-6 sale de Niveles en 0,063, dieciséis códigos de 8 bits. La primera inversa de Oklab del motor usaba las matrices publicadas de diez dígitos y devolvía la identidad solo con un error de 1,18e-6. Ahora es una inversa exacta (1,9e-14), un gradado cuyos controles globales no necesitan Oklab se salta por completo esa pasada, y un caso con γ = 5 en la suite muestra diferencia cero entre JS y C++.

La segunda trampa fue más silenciosa. Durante un tiempo, el motor JavaScript aplicaba los Niveles por canal y el motor C++, que renderiza la vista previa y todas las exportaciones, los descartaba en silencio. Una auditoría independiente encontró diferencias de hasta 0,1875. Una corrección del cuentagotas gris sobre el píxel (158, 128, 97) salía como (127,5, 127,5, 127,5) en JS y sin cambios en C++. Ahora ambos aplican los conjuntos, el parser nativo rechaza nombres de canal desconocidos en lugar de saltarlos, y la comprobación de paridad sobre 257 muestras da 1,1e-16.

Qué mide el histograma

El histograma encima de los controles de Niveles tiene 256 bins. R, G y B se cuentan por separado en los mismos bins, con cada píxel ponderado por su alfa, y los píxeles totalmente transparentes se omiten. Muestrea como máximo 32.768 píxeles con un paso fijo (stride), dibuja la raíz cuadrada de la frecuencia para que los picos no aplanen todo lo demás, y sombrea los rangos por debajo del punto negro y por encima del punto blanco.

Lo que importa es dónde se mide: en la entrada real de Niveles, tomada del propio proceso de renderizado. La primera versión volvía a ejecutar una copia recortada del gradado para el histograma y las lecturas de curva, y la copia conservaba algunas etapas posteriores y descartaba partes de Niveles. Una auditoría detectó un amarillo puro (255, 255, 0) trazado en el histograma de "antes de Niveles" en (229, 217, 102), y una lectura de rojo con el mezclador de canales activo que no coincidía con la entrada real, 139,2 frente a 158,1. Ahora un único pipeline compilado informa de sus propias entradas intermedias, y ambas lecturas usan ese. En los 21 casos de la suite de regresión, el error es 0.

Cuentagotas: tres solucionadores que se niegan

Los tres cuentagotas resuelven parámetros por canal a partir de un único píxel pinchado. Que sea por canal es el punto: la misma corrección en los tres canales no puede igualar canales desiguales, así que no puede eliminar una dominante. Para un objetivo t, la posición que necesita dentro del rango de entrada es u = ((t − ob)/(ow − ob))^γ, y entonces

black eyedropper:  b = (s − u·w) / (1 − u)
white eyedropper:  w = b + (s − b) / u
grey eyedropper:   γ_c = ln(u_c) / ln(v_c),  u_c = (s_c − b)/(w − b),  v_c = (t − ob)/(ow − ob)

Para la solución gris, tanto u_c como v_c deben estar estrictamente dentro de (0, 1). En un extremo no existe gamma, así que un píxel recortado o aplastado se rechaza con su motivo, nunca se sustituye por gamma 1, que parecería que la herramienta no ha hecho nada. Una gamma fuera de 0,2…5 también se rechaza. El objetivo gris por defecto es la media de la propia muestra, así que el píxel conserva aproximadamente su brillo y pierde solo su dominante.

Estos solucionadores son exactos hasta 6,7e-16 en la auditoría. El estado honesto: viven en levels-picker.mjs y todavía no están conectados al panel. El panel edita el conjunto maestro, y los conjuntos por canal solo llegan al motor desde proyectos guardados. El camino de la carta de abajo tiene su propio registro de Niveles por canal en la entrada del gradado.

Puntos de negro y blanco desde una carta

La primera versión de los cuentagotas enviaba el píxel pinchado a negro puro y blanco puro, que coincide con los valores por defecto de Photoshop y es incorrecto para una ColorChecker. Los valores sRGB publicados del Classic24, atribuidos a X-Rite, sitúan el parche blanco en 243 y el parche negro en 52. Llevar el parche blanco a 255 tira todo lo que la escena guarda entre 243 y 255; llevar el parche negro a 0 aplasta 52 niveles de sombra. La guía de exposición de X-Rite lo dice en palabras sencillas: el cuadro blanco debe ser muy claro pero no blanco puro, y el parche negro no debe leerse como negro puro. El parche blanco es un reflector difuso de alrededor del 90 %, así que un brillo especular en el encuadre se supone que es más brillante que él.

Photoshop permite editar los colores objetivo de sus cuentagotas, lo que indica que 0 y 255 son una convención y no una propiedad de la herramienta. Así que los solucionadores toman un objetivo, y hay un preajuste de carta con los valores publicados (para cartas fabricadas después de noviembre de 2014; las cartas más antiguas tienen otros números).

Pinchar negro y luego blanco tiene un segundo problema: el segundo clic mueve el blanco de entrada, y todas las posiciones dentro del rango se mueven con él, así que el parche negro abandona su objetivo. Un cuentagotas a mano alzada no puede evitarlo, porque cada clic solo conoce su propia referencia. Con una carta, ambas referencias son conocidas a la vez, así que el par se resuelve conjuntamente:

w − b = (s_w − s_b) / (u_w − u_b),   b = s_b − u_b · (w − b)

El camino que está conectado de extremo a extremo parte de un modelo UNet/VAE separado de mi proyecto de carta de color. Predice cómo se vería un Classic24 en un punto que elijas del encuadre, lo que da 24 parches virtuales en lugar de una carta física detectada. Un solucionador en Python convierte la carta predicha en Niveles por canal:

# packages/checker/vendor/checker_levels.py, nested inside solve(); docstring omitted.
# black_target and white_target are the chart's published values for patches 24 and 19.
def ends(gamma: np.ndarray):
    low, high = black_target ** gamma, white_target ** gamma
    span = (white_sample - black_sample) / (high - low)
    return black_sample - low * span, span

La gamma sale de los cuatro grises entre los extremos. Cada gris pide su propia gamma por canal, encontrada con 60 pasos de bisección dentro de 0,2…5, y gana la respuesta mediana. Un ajuste por mínimos cuadrados deja que un parche malo arrastre todo: con tres grises que piden gamma 1 y un cuarto aplastado a 0,02, el ajuste pedía 2,74. Un solo gris por sí mismo es frágil de otra forma: en la primera fotografía real que medí, el parche 20 por sí solo era inalcanzable en tres de cada cinco colocaciones, mientras que la rampa alrededor de él era normal.

El solucionador permite puntos de entrada fuera de 0…1, porque un punto negro por debajo de 0 es lo que parece un levantamiento desde el lado de la entrada. Rechazar esos puntos costó cuatro de cada cinco colocaciones en esa primera fotografía. Una prueba de los cuentagotas JS también corrigió una afirmación mía: aseveraba que los objetivos de la carta no recortan nada, y falló, porque con luz cálida el punto blanco de entrada del canal verde cayó en 0,949. Las altas luces pueden ser más brillantes que un parche blanco difuso, así que la aseveración ahora dice lo que realmente dan los objetivos de la carta: el parche deja de ser el techo. Los cuentagotas JS y este solucionador en Python viven en repositorios distintos, así que una prueba fija unos fixtures (datos de prueba) en los que ambos deben coincidir.

El LUT que costó dieciséis códigos

El resultado de la carta se guarda como un pequeño registro inputLevels y se evalúa exactamente, por canal, en la entrada del gradado. Hasta el 23 de septiembre se horneaba en un LUT de entrada de 65³. Los Niveles son separables, así que tanto el muestreo trilineal como el tetraédrico se reducen a interpolación lineal entre nodos separados 1/64, y x^(1/γ) es lo más empinado justo junto al punto negro. La auditoría midió 13,8 códigos de error allí, y la nueva comprobación encontró 16,3 códigos en sus muestras con gamma 3. La evaluación exacta lo baja a 1,1e-16.

Un conjunto de Niveles por canal con gamma 3 evaluado exactamente y a través de un cubo de 65 horneado en LUT: el LUT falla unos 23 códigos junto al punto negro

Reproduje la trampa con el motor para este artículo: negro 0,10, blanco 0,95, gamma 3, horneado en un CUBE de 65³, vuelto a importar y comparado sobre una rampa densa. En una rampa de 2.049 puntos, el peor error fue de 21,3 códigos justo por debajo del punto negro, donde el resultado exacto es 0 y la malla todavía interpola hacia el siguiente nodo, y 10,7 justo por encima; muestreado con suficiente finura, el pico es de unos 22,7 códigos a ambos lados del punto negro. Por encima de la entrada 0,16 el error se queda en torno a un cuarto de código (0,251 como máximo). Una función separable por canal no pertenece a una malla 3D cuando puedes evaluarla exactamente.

Niveles automáticos y color automático: diseñados, no construidos

Todavía no hay un botón Auto en el motor. El diseño está escrito: histogramas de la entrada sin tocar de la etapa y la región elegidas, fracciones de recorte visibles en cada extremo, y puntos de negro y blanco en esos cuantiles. El modo por canal toma un par por canal y cambia el color; el modo compuesto toma un único par y mantiene el eje neutro. Un rango más estrecho que un umbral significa "indefinido", no "identidad", y los parámetros encontrados se guardan para que reabrir un proyecto nunca los recalcule en silencio. Las fracciones deben ser visibles porque incluso la referencia no está resuelta: la página de Adobe sobre ajustes tonales rápidos menciona tanto 0,5 % como 0,1 % como valores de recorte por defecto en secciones distintas.

Límites

  • Niveles trabaja sobre sRGB codificado de 8 bits SDR en la ventana principal. No hay vista previa de recorte sobre la imagen, solo extremos sombreados en el histograma.
  • El panel edita el conjunto maestro. Los cuentagotas por canal existen como solucionadores probados, pero todavía no están conectados a él.
  • Los objetivos de la carta son valores publicados de una generación de cartas, no una lectura de espectrofotómetro de tu carta.
  • No hay niveles ni color automáticos.

Siguiente parte: Curvas: elección de spline, sobreimpulso, luma y horneado en LUT. Mi trabajo de retoque en el dispositivo, un plugin de Photoshop en C++ con sus propios modelos, está 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