Introducción a los Sistemas de Visión Perimetral
Los sistemas de monitorización de entorno, conocidos como AVM (Around View Monitor), son componentes esenciales en las arquitecturas de conducción autónoma y asistencia al estacionamiento. Estos sistemas utilizan un conjunto de cuatro cámaras con lentes ojo de pez ubicadas estratégicamente en el vehículo (parachoques delantero, maletero y espejos retrovisores) para generar una vista cenital del entorno inmediato.
El flujo de procesamiento típico de un pipeline AVM incluye: corrección de distorsión de lentes, calibración conjunta de las cuatro cámaras, transformación de perspectiva, ajuste de la vista cenital, fusión de imágenes y, en implementaciones avanzadas, mapeo de texturas en modelos 3D.
Clasificación de los Algoritmos AVM
Las soluciones de visión perimetral se dividen principalmente en dos categorías:
- AVM 2D: Proyecta las imágenes directamente sobre un plano horizontal, generando una vista cenital plana.
- AVM 3D: Mapea las texturas de las cámaras sobre una malla tridimensional (como un cuenco o una esfera), permitiendo cambiar el ángulo de visión dinámicamente.
Corrección de Distorsión y Calibración Intrínseca
El primer paso consiste en obtener la matriz de parámetros intrínsecos y los coeficientes de distorsión de cada sensor. Para ello, se emplea el método de calibración de tablero de ajedrez de Zhang. Este algoritmo calcula una solución inicial mediante deducción matricial y luego aplica optimización no lineal para refinar los parámetros intrínsecos, extrínsecos y de distorsión. Una vez obtenidos, se aplica la corrección a las imágenes crudas de los sensores ojo de pez.
Calibración Extrínseca Conjunta
Para transformar las imágenes corregidas en una vista cenital coherente, es necesario calcular las matrices de proyección de cada cámara hacia el plano del suelo. Estas matrices no son independientes; deben garantizar que las regiones proyectadas encajen perfectaemnte.
Este proceso se realiza mediante una calibración conjunta utilizando un patrón de calibración físico (como una lona con cuadrículas) colocado alrededor del vehículo. El patrón debe ser visible simultáneamente por las cámaras adyacentes en sus zonas de superposición.
Se deben definir varios parámetros geométricos (en centímetros):
- Desplazamiento interno: Distancia entre el borde interior del patrón y los lados del vehículo.
- Desplazamiento externo: Determina qué tan lejos se extiende la vista cenital más allá del patrón. Valores mayores amplían el campo de visión pero incrementan la distorsión en los bordes.
- Dimensiones del lienzo: El ancho y alto total de la imagen de salida, calculado en base al tamaño del patrón y los desplazamientos externos, asumiendo una resolución de 1 píxel por centímetro.
- Zona ciega del vehículo: Un rectángulo central que representa la carrocería del automóvil, el cual será cubierto posteriormente con un gráfico del vehículo.
Transformación de Perspectiva y Homografía
La transformación de perspectiva se basa en la matriz de homografía (H), que relaciona dos planos diferentes. En el contexto AVM, mapea los puntos del plano de la imagen corregida al plano de la vista cenital.
Utilizando el método de calibración con lona, se seleccionan manualmente cuatro puntos de control en la imagen de origen y sus correspondientes coordenadas en la imagen de destino. Dado que cuatro pares de puntos proporcionan ocho ecuaciones, es posible resolver los ocho grados de libertad de la matriz de homografía (asumiendo que el último elemento de la matriz es 1). Esto evita la necesidad de calcular explícitamente los parámetros extrínsecos de cada cámara respecto a la carrocería, ya que la homografía encapsula implícitamente esta relación geométrica.
Fusión de Imágenes y Suavizado de Bordes
El paso crítico del pipeline es la fusión de las vistas cenitales individuales. Las cámaras adyacentes tienen campos de visión superpuestos. Si se utiliza un promedio ponderado simple (50% para cada imagen), las imprecisiones en la calibración y proyección causarán efectos de "fantasma" o duplicación de objetos.
Para resolver esto, se implementa una técnica de fusión basada en distancias. Se genera una máscara (mask) para la región de superposición mediante binarización y operaciones morfológicas. Luego, se calcula un peso dinámico para cada píxel basado en su distancia a los límites de la zona de superposición. Los píxeles más cercanos al límite izquierdo tendrán mayor peso de la cámara izquierda, y viceversa, garantizando una transición suave y continua sin efectos de borde abruptos.
Implementación en C++ con OpenCV
A continuación, se presenta una implementación refactorizada del procesador AVM utilizando C++ y la biblioteca OpenCV. El código ha sido modernizado para mejorar la legibilidad y la gestión de memoria.
main.cpp
#include "AvmProcessor.hpp"
#include <opencv2/highgui.hpp>
#include <iostream>
int main() {
std::vector<cv::Mat> cameraFrames(4);
const std::vector<std::string> filePaths = {"front.png", "rear.png", "left.png", "right.png"};
for (size_t i = 0; i < filePaths.size(); ++i) {
cameraFrames[i] = cv::imread(filePaths[i]);
if (cameraFrames[i].empty()) {
std::cerr << "Error al cargar la imagen: " << filePaths[i] << std::endl;
return -1;
}
}
AvmProcessor avmSystem("avm_config.yml");
avmSystem.configureVehicleDimensions(240, 380);
avmSystem.configurePatternDimensions(60, 60);
avmSystem.configureBlendMaskHeight(200);
avmSystem.configureInternalOffsets(27, 27);
// Descomentar para calibración interactiva
// avmSystem.captureControlPoints(cameraFrames);
cv::namedWindow("Vista Cenital AVM", cv::WINDOW_AUTOSIZE);
while (true) {
cv::Mat birdViewResult = avmSystem.generateBirdView(cameraFrames);
cv::imshow("Vista Cenital AVM", birdViewResult);
if (cv::waitKey(30) == 27) { // Tecla ESC para salir
break;
}
}
return 0;
}
AvmProcessor.hpp
#pragma once
#include <opencv2/opencv.hpp>
#include <vector>
#include <string>
#include <iostream>
class AvmProcessor {
public:
explicit AvmProcessor(const std::string& configPath = "") {
isCalibrated = false;
areParamsReady = false;
currentCamIdx = 0;
clickCounter = 0;
srcCorners.resize(4, std::vector<cv::Point2f>(4));
dstCorners.resize(4, std::vector<cv::Point2f>(4));
try {
vehicleOverlay = cv::imread("../assets/vehicle_top.png", cv::IMREAD_UNCHANGED);
} catch (...) {
std::cerr << "[ADVERTENCIA] No se encontró la imagen del vehículo.\n";
}
if (!configPath.empty()) {
loadConfiguration(configPath);
}
}
void configureInternalOffsets(int width, int height) {
internalShift = cv::Size(width, height);
areParamsReady = false;
initializeParameters();
}
void configureExternalOffsets(int width, int height) {
externalShift = cv::Size(width, height);
areParamsReady = false;
initializeParameters();
}
void configureVehicleDimensions(int width, int height) {
vehicleSize = cv::Size(width, height);
areParamsReady = false;
initializeParameters();
}
void configurePatternDimensions(int width, int height) {
patternSize = cv::Size(width, height);
areParamsReady = false;
initializeParameters();
}
void configureBlendMaskHeight(int height) {
maskHeight = height;
areParamsReady = false;
initializeParameters();
}
cv::Mat generateBirdView(const std::vector<cv::Mat>& frames) {
if (!isCalibrated) {
throw std::runtime_error("Puntos de control no definidos. Ejecute captureControlPoints primero.");
}
if (!areParamsReady) {
initializeParameters();
}
cv::Mat canvas(canvasSize, CV_8UC3, cv::Scalar(0, 0, 0));
std::vector<cv::Mat> warpedFrames(4);
// Orden de procesamiento: Izquierda, Delante, Derecha, Detrás
const int processingOrder[] = {0, 2, 1, 3};
for (int i = 0; i < 4; ++i) {
int idx = processingOrder[i];
if (frames[idx].empty()) continue;
cv::warpPerspective(frames[idx], warpedFrames[idx], homographyMatrices[idx], canvasSize);
cv::Mat currentMask;
if (idx == 1) currentMask = frontBlendMask;
else if (idx == 3) currentMask = rearBlendMask;
else currentMask = cv::Mat();
if (!currentMask.empty()) {
warpedFrames[idx](roiRegions[idx]).copyTo(canvas(roiRegions[idx]), currentMask);
} else {
warpedFrames[idx](roiRegions[idx]).copyTo(canvas(roiRegions[idx]));
}
}
if (!vehicleOverlay.empty() && !resizedOverlay.empty()) {
resizedOverlay.copyTo(canvas(vehicleOverlayPos), resizedOverlayAlpha);
}
return canvas;
}
void saveConfiguration(const std::string& path = "avm_config.yml") {
cv::FileStorage fs(path, cv::FileStorage::WRITE);
if (!fs.isOpened()) return;
for (int i = 0; i < 4; ++i) {
for (int k = 0; k < 4; ++k) {
std::string key = "src_pt_" + std::to_string(i) + "_" + std::to_string(k);
fs << key << srcCorners[i][k];
}
}
fs.release();
std::cout << "Configuración guardada exitosamente.\n";
}
void loadConfiguration(const std::string& path) {
cv::FileStorage fs(path, cv::FileStorage::READ);
if (!fs.isOpened()) {
std::cerr << "No se pudo abrir el archivo de configuración.\n";
return;
}
for (int i = 0; i < 4; ++i) {
for (int k = 0; k < 4; ++k) {
std::string key = "src_pt_" + std::to_string(i) + "_" + std::to_string(k);
fs[key] >> srcCorners[i][k];
}
}
fs.release();
isCalibrated = true;
areParamsReady = false;
initializeParameters();
}
void captureControlPoints(std::vector<cv::Mat>& frames) {
initializeParameters(true);
cv::namedWindow("Calibración AVM");
cv::setMouseCallback("Calibración AVM", mouseCallback, this);
for (currentCamIdx = 0, clickCounter = 0; currentCamIdx < 4;) {
cv::Mat displayFrame = frames[currentCamIdx].clone();
for (int i = 0; i < clickCounter; ++i) {
cv::circle(displayFrame, srcCorners[currentCamIdx][i], 5, cv::Scalar(255, 0, 0), 2);
}
cv::imshow("Calibración AVM", displayFrame);
if (cv::waitKey(20) == 'j') break; // Saltar cámara
}
cv::destroyWindow("Calibración AVM");
saveConfiguration();
isCalibrated = true;
}
private:
static void mouseCallback(int event, int x, int y, int flags, void* userdata) {
if (event == cv::EVENT_LBUTTONUP) {
AvmProcessor* self = static_cast<AvmProcessor*>(userdata);
self->srcCorners[self->currentCamIdx][self->clickCounter] = cv::Point2f(x, y);
self->clickCounter++;
if (self->clickCounter > 3) {
self->clickCounter = 0;
self->homographyMatrices[self->currentCamIdx] = cv::getPerspectiveTransform(
self->srcCorners[self->currentCamIdx], self->dstCorners[self->currentCamIdx]);
self->currentCamIdx++;
}
}
}
void initializeParameters(bool isInteractive = false) {
if (externalShift.area() == 0) externalShift = cv::Size(200, 200);
if (patternSize.area() == 0) patternSize = cv::Size(60, 60);
if (internalShift.area() == 0) internalShift = cv::Size(36, 27);
if (vehicleSize.area() == 0) vehicleSize = cv::Size(240, 380);
if (maskHeight <= 0 || maskHeight >= 100) maskHeight = 200;
if (!areParamsReady) {
canvasSize = cv::Size(
externalShift.width * 2 + vehicleSize.width + patternSize.width * 2,
externalShift.height * 2 + vehicleSize.height + patternSize.height * 2
);
// Calcular esquinas de destino (dstCorners) para cada cámara
// Izquierda (0)
dstCorners[0] = {
cv::Point2f(externalShift.width + patternSize.width, externalShift.height),
cv::Point2f(externalShift.width + patternSize.width, canvasSize.height - externalShift.height),
cv::Point2f(externalShift.width, canvasSize.height - externalShift.height),
cv::Point2f(externalShift.width, externalShift.height)
};
// Delante (1)
dstCorners[1] = {
cv::Point2f(canvasSize.width - externalShift.width, externalShift.height + patternSize.height),
cv::Point2f(externalShift.width, externalShift.height + patternSize.height),
cv::Point2f(externalShift.width, externalShift.height),
cv::Point2f(canvasSize.width - externalShift.width, externalShift.height)
};
// Detrás (3)
dstCorners[3] = {
cv::Point2f(externalShift.width, canvasSize.height - externalShift.height - patternSize.height),
cv::Point2f(canvasSize.width - externalShift.width, canvasSize.height - externalShift.height - patternSize.height),
cv::Point2f(canvasSize.width - externalShift.width, canvasSize.height - externalShift.height),
cv::Point2f(externalShift.width, canvasSize.height - externalShift.height)
};
// Derecha (2)
dstCorners[2] = {
cv::Point2f(canvasSize.width - externalShift.width - patternSize.width, externalShift.height),
cv::Point2f(canvasSize.width - externalShift.width - patternSize.width, canvasSize.height - externalShift.height),
cv::Point2f(canvasSize.width - externalShift.width, canvasSize.height - externalShift.height),
cv::Point2f(canvasSize.width - externalShift.width, externalShift.height)
};
// Definir regiones de interés (ROI)
roiRegions[0] = cv::Rect(0, 0, externalShift.width + patternSize.width + internalShift.width, canvasSize.height);
roiRegions[1] = cv::Rect(0, 0, canvasSize.width, externalShift.height + patternSize.height + internalShift.height);
roiRegions[2] = cv::Rect(canvasSize.width - externalShift.width - patternSize.width - internalShift.width, 0, externalShift.width + patternSize.width + internalShift.width, canvasSize.height);
roiRegions[3] = cv::Rect(0, canvasSize.height - externalShift.height - patternSize.height - internalShift.height, canvasSize.width, externalShift.height + patternSize.height + internalShift.height);
generateBlendMasks();
if (isCalibrated) {
for (int i = 0; i < 4; ++i) {
homographyMatrices[i] = cv::getPerspectiveTransform(srcCorners[i], dstCorners[i]);
}
}
if (!vehicleOverlay.empty()) {
cv::Size overlaySize(vehicleSize.width - 2 * internalShift.width, vehicleSize.height - 2 * internalShift.height);
cv::resize(vehicleOverlay, resizedOverlay, overlaySize, 0, 0, cv::INTER_CUBIC);
vehicleOverlayPos = cv::Rect(
externalShift.width + patternSize.width + internalShift.width,
externalShift.height + patternSize.height + internalShift.height,
overlaySize.width, overlaySize.height
);
// Separar canales alfa para la máscara del vehículo
std::vector<cv::Mat> channels;
cv::split(resizedOverlay, channels);
if (channels.size() == 4) {
resizedOverlayAlpha = channels[3];
}
}
areParamsReady = true;
}
}
void generateBlendMasks() {
frontBlendMask = cv::Mat(roiRegions[1].size(), CV_8UC1, cv::Scalar(255));
rearBlendMask = cv::Mat(roiRegions[3].size(), CV_8UC1, cv::Scalar(255));
std::vector<std::vector<cv::Point>> frontPolygons(2);
frontPolygons[0] = {
cv::Point(0, roiRegions[1].height),
cv::Point(0, roiRegions[1].height - maskHeight),
cv::Point(roiRegions[0].width, roiRegions[1].height)
};
frontPolygons[1] = {
cv::Point(roiRegions[1].width, roiRegions[1].height),
cv::Point(roiRegions[1].width, roiRegions[1].height - maskHeight),
cv::Point(roiRegions[1].width - roiRegions[2].width, roiRegions[1].height)
};
std::vector<std::vector<cv::Point>> rearPolygons(2);
rearPolygons[0] = {
cv::Point(0, 0),
cv::Point(0, maskHeight),
cv::Point(roiRegions[0].width, 0)
};
rearPolygons[1] = {
cv::Point(canvasSize.width, 0),
cv::Point(canvasSize.width, maskHeight),
cv::Point(canvasSize.width - roiRegions[2].width, 0)
};
cv::fillPoly(frontBlendMask, frontPolygons, cv::Scalar(0));
cv::fillPoly(rearBlendMask, rearPolygons, cv::Scalar(0));
}
// Variables de estado
bool isCalibrated;
bool areParamsReady;
int currentCamIdx;
int clickCounter;
int maskHeight;
// Matrices y transformaciones
cv::Mat homographyMatrices[4];
cv::Mat frontBlendMask, rearBlendMask;
cv::Mat vehicleOverlay, resizedOverlay, resizedOverlayAlpha;
// Geometría
std::vector<std::vector<cv::Point2f>> srcCorners, dstCorners;
cv::Rect roiRegions[4], vehicleOverlayPos;
cv::Size externalShift, internalShift, patternSize, canvasSize, vehicleSize;
};