Corrección de color de vídeo con Python y OpenCV
La corrección de color transforma el aspecto visual de un vídeo para lograr coherencia cromática, estilo cinematográfico o compensar defectos de la cámara. Python ofrece herramientas potentes: OpenCV para procesamiento frame a frame, colour-science para transformaciones de espacio de color precisas, y LUTs 3D para aplicar looks de forma eficiente.
Conceptos clave
- Corrección primaria: ajuste de exposición, contraste, balance de blancos y saturación
- Corrección secundaria: modificación de colores específicos (aislar el cielo, pieles, etc.)
- LUT (Look-Up Table): tabla de mapeo 3D que transforma cada color de entrada en un color de salida
- Espacio de color: sRGB, BT.709, BT.2020, ACES, Log C, S-Log — cada cámara y pantalla usa uno diferente
Instalación
pip install opencv-python numpy colour-science imageio
Ajustes básicos: brillo, contraste, saturación
import cv2
import numpy as np
def ajustar_imagen(frame, brillo=0, contraste=1.0, saturacion=1.0, gamma=1.0):
"""
Aplica ajustes básicos de color a un frame.
brillo: -100 a +100
contraste: 0.5 a 2.0 (1.0 = sin cambio)
saturacion: 0.0 a 3.0 (0.0 = escala de grises)
gamma: 0.5 a 2.5 (1.0 = sin cambio)
"""
# Brillo y contraste
frame = cv2.convertScaleAbs(frame, alpha=contraste, beta=brillo)
# Corrección gamma
if gamma != 1.0:
tabla = np.array([
((i / 255.0) ** (1.0 / gamma)) * 255
for i in range(256)
], dtype=np.uint8)
frame = cv2.LUT(frame, tabla)
# Saturación en espacio HSV
if saturacion != 1.0:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype(np.float32)
hsv[..., 1] = np.clip(hsv[..., 1] * saturacion, 0, 255)
frame = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
return frame
# Procesar vídeo completo
cap = cv2.VideoCapture("video_original.mp4")
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter(
"video_corregido.mp4",
cv2.VideoWriter_fourcc(*'mp4v'),
fps, (w, h)
)
while True:
ret, frame = cap.read()
if not ret:
break
frame_corregido = ajustar_imagen(
frame,
brillo=10,
contraste=1.15,
saturacion=1.3,
gamma=0.9
)
out.write(frame_corregido)
cap.release()
out.release()
print("Vídeo procesado")
Balance de blancos automático
import cv2
import numpy as np
def balance_blancos_gray_world(frame):
"""
Algoritmo Gray World: asume que el promedio de todos los colores
debería ser gris neutro.
"""
b, g, r = cv2.split(frame.astype(np.float32))
media_r = r.mean()
media_g = g.mean()
media_b = b.mean()
media = (media_r + media_g + media_b) / 3
r = np.clip(r * (media / media_r), 0, 255)
g = np.clip(g * (media / media_g), 0, 255)
b = np.clip(b * (media / media_b), 0, 255)
return cv2.merge([b, g, r]).astype(np.uint8)
def balance_blancos_temperatura(frame, temperatura_k):
"""
Simula ajuste de temperatura de color en Kelvin.
temperatura_k: 2000 (cálido/naranja) - 9000 (frío/azul)
"""
# Aproximación RGB para temperatura de color
temp = temperatura_k / 100.0
if temp <= 66:
r = 255
g = max(0, min(255, 99.4708025861 * np.log(temp) - 161.1195681661))
b = 0 if temp <= 19 else max(0, min(255, 138.5177312231 * np.log(temp - 10) - 305.0447927307))
else:
r = max(0, min(255, 329.698727446 * ((temp - 60) ** -0.1332047592)))
g = max(0, min(255, 288.1221695283 * ((temp - 60) ** -0.0755148492)))
b = 255
# Crear máscara de temperatura y mezclarla
mascara = np.zeros_like(frame, dtype=np.float32)
mascara[:, :] = [b, g, r] # BGR
# Mezcla suave con el frame original
resultado = cv2.addWeighted(
frame.astype(np.float32), 0.7,
mascara, 0.3, 0
)
return np.clip(resultado, 0, 255).astype(np.uint8)
frame = cv2.imread("frame_azulado.jpg")
frame_bb = balance_blancos_gray_world(frame)
cv2.imwrite("frame_bb.jpg", frame_bb)
Curvas de tono (Tone Curves)
import cv2
import numpy as np
from scipy.interpolate import CubicSpline
def construir_curva(puntos_control):
"""
Construye una curva de tono a partir de puntos de control.
puntos_control: lista de (entrada, salida) — valores 0-255
"""
x = np.array([p[0] for p in puntos_control])
y = np.array([p[1] for p in puntos_control])
cs = CubicSpline(x, y)
# Generar tabla LUT de 256 valores
lut = np.clip(cs(np.arange(256)), 0, 255).astype(np.uint8)
return lut
def aplicar_curvas(frame, curva_r=None, curva_g=None, curva_b=None, curva_rgb=None):
"""Aplica curvas de tono por canal."""
b, g, r = cv2.split(frame)
if curva_rgb is not None:
b = cv2.LUT(b, curva_rgb)
g = cv2.LUT(g, curva_rgb)
r = cv2.LUT(r, curva_rgb)
if curva_r is not None:
r = cv2.LUT(r, curva_r)
if curva_g is not None:
g = cv2.LUT(g, curva_g)
if curva_b is not None:
b = cv2.LUT(b, curva_b)
return cv2.merge([b, g, r])
# Curva en S (aumenta contraste)
curva_s = construir_curva([
(0, 0),
(64, 50),
(128, 128),
(192, 210),
(255, 255),
])
# Look cinematográfico: sombras azuladas, altas luces cálidas
curva_r_cine = construir_curva([(0, 10), (128, 128), (255, 245)])
curva_g_cine = construir_curva([(0, 5), (128, 125), (255, 250)])
curva_b_cine = construir_curva([(0, 20), (128, 130), (255, 240)])
frame = cv2.imread("frame.jpg")
cine = aplicar_curvas(frame, curva_r_cine, curva_g_cine, curva_b_cine)
cv2.imwrite("frame_cine.jpg", cine)
Aplicar LUTs 3D (.cube)
Las LUTs .cube son el estándar de la industria (DaVinci Resolve, Premiere Pro, etc.):
import numpy as np
import cv2
def cargar_lut_cube(ruta_cube):
"""Carga una LUT 3D desde un archivo .cube."""
con = {}
datos = []
with open(ruta_cube, 'r') as f:
for linea in f:
linea = linea.strip()
if linea.startswith('#') or not linea:
continue
if linea.startswith('LUT_SIZE'):
con['size'] = int(linea.split()[-1])
elif linea.startswith('DOMAIN_MIN'):
con['min'] = list(map(float, linea.split()[1:]))
elif linea.startswith('DOMAIN_MAX'):
con['max'] = list(map(float, linea.split()[1:]))
elif linea[0].isdigit() or linea[0] == '-':
datos.append(list(map(float, linea.split())))
size = con['size']
lut = np.array(datos, dtype=np.float32).reshape(size, size, size, 3)
return lut, size
def aplicar_lut_3d(frame, lut, size):
"""Aplica una LUT 3D a un frame BGR."""
frame_f = frame.astype(np.float32) / 255.0
# Convertir a RGB para la LUT (que usa orden R,G,B)
r = frame_f[:, :, 2]
g = frame_f[:, :, 1]
b = frame_f[:, :, 0]
# Escalar a índices de LUT
ri = np.clip(r * (size - 1), 0, size - 1).astype(int)
gi = np.clip(g * (size - 1), 0, size - 1).astype(int)
bi = np.clip(b * (size - 1), 0, size - 1).astype(int)
# Lookup (R→eje0, G→eje1, B→eje2 según estándar .cube)
resultado_rgb = lut[ri, gi, bi]
# Volver a BGR
resultado_bgr = resultado_rgb[:, :, ::-1]
return np.clip(resultado_bgr * 255, 0, 255).astype(np.uint8)
# Aplicar LUT a todo un vídeo
lut, size = cargar_lut_cube("fuji_eterna.cube")
cap = cv2.VideoCapture("grabacion.mp4")
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter("grabacion_lut.mp4", cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
out.write(aplicar_lut_3d(frame, lut, size))
frame_count += 1
if frame_count % 100 == 0:
print(f" Procesados {frame_count} frames...")
cap.release()
out.release()
print(f"Completado: {frame_count} frames con LUT '{lut}'")
Conversión de espacio de color con colour-science
import colour
import numpy as np
import cv2
def slog3_a_srgb(frame_bgr):
"""Convierte Sony S-Log3 a sRGB para monitorización."""
frame_rgb = frame_bgr[:, :, ::-1].astype(np.float32) / 255.0
# S-Log3 → lineal
lineal = colour.log_decoding(frame_rgb, function='S-Log3')
# Lineal → sRGB (con conversión de primarias S-Gamut3 → BT.709)
m = colour.matrix_RGB_to_RGB(
colour.RGB_COLOURSPACES['S-Gamut3'],
colour.RGB_COLOURSPACES['sRGB']
)
srgb_lineal = np.dot(lineal, m.T)
# Aplicar curva de transferencia sRGB
srgb = colour.oetf(np.clip(srgb_lineal, 0, 1), function='sRGB')
return (np.clip(srgb, 0, 1) * 255).astype(np.uint8)[:, :, ::-1]
def logc_a_srgb(frame_bgr):
"""Convierte ARRI Log C a sRGB."""
frame_rgb = frame_bgr[:, :, ::-1].astype(np.float32) / 255.0
lineal = colour.log_decoding(frame_rgb, function='Log C')
m = colour.matrix_RGB_to_RGB(
colour.RGB_COLOURSPACES['ALEXA Wide Gamut'],
colour.RGB_COLOURSPACES['sRGB']
)
srgb_lineal = np.dot(lineal, m.T)
srgb = colour.oetf(np.clip(srgb_lineal, 0, 1), function='sRGB')
return (np.clip(srgb, 0, 1) * 255).astype(np.uint8)[:, :, ::-1]
frame = cv2.imread("frame_slog3.png")
srgb = slog3_a_srgb(frame)
cv2.imwrite("frame_srgb.png", srgb)
Look cinematográfico completo
def look_cinematografico(frame):
"""
Aplica un look de película:
- Crush de negros (sombras no llegan al negro puro)
- Curva en S suave
- Desaturación leve en altas luces
- Viñeta
"""
h, w = frame.shape[:2]
# 1. Crush de negros (lift)
frame = frame.astype(np.float32)
frame = np.clip(frame * 0.94 + 12, 0, 255)
# 2. Curva en S
lut_s = construir_curva([(0, 10), (60, 50), (128, 128), (200, 210), (255, 245)])
frame = cv2.LUT(frame.astype(np.uint8), lut_s).astype(np.float32)
# 3. Reducir saturación en altas luces
hsv = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_BGR2HSV).astype(np.float32)
mascara_hl = hsv[:, :, 2] / 255.0
hsv[:, :, 1] = hsv[:, :, 1] * (1 - mascara_hl * 0.3)
frame = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR).astype(np.float32)
# 4. Viñeta
cy, cx = h // 2, w // 2
Y, X = np.ogrid[:h, :w]
dist = np.sqrt((X - cx)**2 + (Y - cy)**2)
radio = np.sqrt(cx**2 + cy**2)
vignette = 1 - np.clip(dist / radio * 0.6, 0, 0.5)
for c in range(3):
frame[:, :, c] *= vignette
return np.clip(frame, 0, 255).astype(np.uint8)
Recurso adicional
Para convertir vídeos entre MP4, MOV, MKV, AVI y más formatos sin código, usa KaijuConverter — gratis y sin registro.
Conversiones relacionadas
Conversiones de vídeo que encajan con esta guía: