Una aplicación simple de captura de vídeo usando DirectShow
En el artículo anterior de la serie hice un resumen de los elementos básicos de DirectShow: filtros, grafos de filtros y pines para conectar los elementos entre sí, y como podemos identificarlos utilizando la herramienta del SDK GraphEdit. En este artículo voy a mostrar cómo construir una “sencilla” aplicación de captura y reproducción de vídeo utilizando los interfaces que proporciona DirectShow, un subconjunto del modelo de objetos distribuidos de la plataforma COM de Microsoft.
Como punto de partida, me he basado en el proyecto de código abierto SDK Touchless, que he simplificado lo más posible para centrarme solo en la captura y reproducción de vídeo. En este enlace podéis descargar el proyecto DirectShowDemo, desarrollado con Visual Studio 2013.
El proyecto consta de una aplicación Windows Forms escrita en C# y de una librería de funciones escrita en C++, que es una modificación de la librería WebCamLib.dll del proyecto Touchless que permite utilizar un número mayor de fuentes de vídeo, incluyendo la reproducción de archivos.
Los objetos de la plataforma COM están identificados de forma unívoca mediante un CLSID (identificador de clase), e implementan una serie de interfaces identificados también de forma unívoca por un IID (identificador de interfaz). Para instanciar un determinado objeto se utiliza la función CoCreateInstance, a la que se le pasa un identificador de clase y el identificador del interfaz con el que queremos ver el objeto. A partir de ahí, podemos llamar a las funciones que componen dicho interfaz para trabajar con el objeto.
Todos los interfaces COM se derivan del interfaz genérico IUnknown, mediante el cual podemos ver el objeto creado con un interfaz diferente usando la función QueryInterface. Podemos ver cualquier objeto COM como un objeto genérico, similar al tipo object, mediante el interfaz IMoniker.
Desde este punto de vista, cada filtro es un objeto con un identificador de clase diferente, que implementa el interfaz IBaseFilter, un grafo de filtros genérico es otro objeto de la clase FilterGraph que implementa el interfaz IGraphBuilder, al que le iremos añadiendo los diferentes filtros mediante la función AddFilter, al igual que hicimos en el artículo anterior con el programa GraphEdit.
Las funciones COM devuelven como resultado un código llamado HRESULT, que nos indica si la llamada ha tenido éxito o el código del error que se ha producido. Esto lo podemos comprobar usando la macro SUCCESS.
Obtener la lista de cámaras presentes en el sistema
Vamos a ver primero cómo enumerar los diferentes dispositivos de captura de vídeo existentes en el equipo. En la librería VideoCamLib se encuentra la función RefreshCameraList que realiza este trabajo.
En primer lugar, utilizamos CoCreateInstance para instanciar un objeto de la clase SystemDeviceEnum con el interfaz ICreateDevEnum:
HRESULT hr = CoCreateInstance(CLSID_SystemDeviceEnum,
NULL,
CLSCTX_INPROC,
IID_ICreateDevEnum,
(LPVOID*)&pdevEnum);
A continuación, usamos este objeto para crear un enumerador de fuentes de vídeo, mediante la función CreateClassEnumerator:
hr = pdevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory,
&pclassEnum, 0);
La enumeración de fuentes de vídeo se realiza con la función Next de este interfaz:
pclassEnum->Next(1, apIMoniker, &ulCount);
Lo que nos devuelve esta función es un objeto genérico IMoniker. Este objeto representa el dispositivo, y lo almacenaremos en un array para utilizarlo posteriormente:
g_aCameraInfo[*nCount].pMoniker = apIMoniker[0];
Para obtener las propiedades del objeto, identificadas por el interfaz IPropertyBag, utilizamos la función BindToStorage:
hr = apIMoniker[0]->BindToStorage(0, 0, IID_IPropertyBag, (void **)&pPropBag);
Por último, obtenemos la propiedad FriendlyName con el nombre del dispositivo mediante la función Read:
hr = pPropBag->Read(L"FriendlyName", &varName, 0);
No hay que olvidarse de liberar los objetos COM mediante la función Release una vez que los hemos utilizado, pues de lo contrario permanecerán en la memoria y esta se acabará saturando.
Captura de vídeo desde un dispositivo
Para capturar vídeo desde uno cualquiera de los dispositivos que hemos obtenido, crearemos un grafo de filtros que empezará en el dispositivo, pasará a través de un filtro SampleGrabber, que nos irá proporcionando cada uno de los frames mediante una función callback y terminará en un filtro NullRenderer que es simplemente un punto final que no realiza ninguna acción, ya que será nuestro programa el que se encargue de procesar y mostrar las imágenes.
Para iniciar la captura, utilizaremos la función StartCamera, a la que le pasaremos el dispositivo como un interfaz genérico IUnknown y una función callback para que nos vaya pasando las imágenes.
Obtendremos el objeto genérico IMoniker utilizando la función QueryInterface de IUnknown:
hr = pUnk->QueryInterface(IID_IMoniker, (LPVOID*)&pMoniker);
Mediante la función CoCreateInstance, crearemos un objeto de la clase genérica FilterGraph con la interfaz IGraphBuilder para construir el grafo de filtros:
hr = CoCreateInstance(CLSID_FilterGraph,
NULL,
CLSCTX_INPROC,
IID_IGraphBuilder,
(LPVOID*)&g_pGraphBuilder);
A partir de este objeto obtendremos el interfaz IMediaControl, que nos permitirá iniciar la reproducción:
hr = g_pGraphBuilder->QueryInterface(IID_IMediaControl,
(LPVOID*)&g_pMediaControl);
También debemos crear un objeto especializado en la construcción de grafos de filtros de captura, que nos permitirá enlazar correctamente los pines de los diferentes filtros de vídeo:
hr = CoCreateInstance(CLSID_CaptureGraphBuilder2,
NULL,
CLSCTX_INPROC,
IID_ICaptureGraphBuilder2,
(LPVOID*)&g_pCaptureGraphBuilder);
Y le asociaremos el IGraphBuilder genérico, en el cual iremos añadiendo los diferentes filtros, usando la función SetFiltergraph:
hr = g_pCaptureGraphBuilder->SetFiltergraph(g_pGraphBuilder);
Como el objeto IMoniker que representa la cámara es un objeto genérico, primero lo debemos convertir en un filtro, con el interfaz IBaseFilter, mediante la función BindToObject:
hr = pMoniker->BindToObject(NULL, NULL, IID_IBaseFilter,
(LPVOID*)&g_pIBaseFilterCam);
Ahora ya podemos añadirlo al grafo de filtros mediante la función AddFilter:
hr = g_pGraphBuilder->AddFilter(g_pIBaseFilterCam, L"VideoCam");
Crearemos un filtro de la clase SampleGrabber usando de nuevo la función CoCreateInstance:
hr = CoCreateInstance(CLSID_SampleGrabber, NULL, CLSCTX_INPROC_SERVER,
IID_IBaseFilter, (void**)&g_pIBaseFilterSampleGrabber);
Con la función ConfigureSampleGrabber de la librería, configuraremos el filtro para que nos pase las imágenes en un formato de vídeo determinado y le enlazaremos la función callback para que nos pase las imágenes:
hr = ConfigureSampleGrabber(g_pIBaseFilterSampleGrabber);
Para, a continuación, añadirlo al grafo con la función AddFilter, como anteriormente. El último filtro de nuestro grafo es de la clase NullRenderer. Este filtro, aunque no realiza ninguna acción, es necesario para completar el grafo, pues debe terminar en un filtro renderer para que esté completo. Lo creamos utilizando de nuevo la función CoCreateInstance:
hr = CoCreateInstance(CLSID_NullRenderer, NULL, CLSCTX_INPROC_SERVER,
IID_IBaseFilter, (void**)&g_pIBaseFilterNullRenderer);
Y lo añadimos al grafo con AddFilter. Ahora vamos a configurar el pin de salida de la cámara con los parámetros de tamaño de imagen que deseemos, de entre los que la cámara nos ofrezca, para ello obtendremos el interfaz IAMStreamConfig del pin de captura mediante la función FindInterface del CaptureGraphBuilder:
hr = g_pCaptureGraphBuilder->FindInterface(
&PIN_CATEGORY_CAPTURE,
0,
g_pIBaseFilterCam,
IID_IAMStreamConfig, (void**)&pConfig);
Los datos con las características del canal son devueltos en una estructura de tipo AM_MEDIA_TYPE, obtendremos la cuenta con todas las posibilidades ofrecidas mediante la función GetNumberofCapabilities:
hr = pConfig->GetNumberOfCapabilities(&iCount, &iSize);
Y seleccionaremos entre todas ellas la que se ajuste mejor a nuestras necesidades. Cada elemento lo obtendremos usando la función GetStreamCaps:
hr = pConfig->GetStreamCaps(iFormat, &pmtConfig, (BYTE*)&scc);
Esta estructura contiene una estructura de tipo VIDEOINFOHEADER en el miembro pbFormat, que a su vez contiene otra estructura bmiHeader donde podemos encontrar el ancho y alto de los frames en sus miembros biWidth y biHeight. Cuando hayamos encontrado la que mejor se ajusta, usaremos la función SetFormat para seleccionarla:
pConfig->SetFormat(pmtConfig);
Y ahora, lo único que nos queda por hacer para completar el grafo es conectar entre sí los pines de entrada y de salida de todos los filtros que hemos ido añadiendo. Esto se puede hacer obteniendo cada uno de los pines de salida de cada filtro y los de entrada del siguiente y seleccionando el más apropiado para realizar la conexión, pero existe una forma mucho más sencilla de hacerlo, dejando que sea DirectShow el que realice esta tarea, mediante la función RenderStream del objeto CaptureGraphBuilder:
hr = g_pCaptureGraphBuilder->RenderStream(&PIN_CATEGORY_CAPTURE,
&MEDIATYPE_Video, g_pIBaseFilterCam,
g_pIBaseFilterSampleGrabber, g_pIBaseFilterNullRenderer);
A esta función le pasamos los tres filtros que componen el grafo, el filtro de captura, el filtro intermedio y el renderer final. Si fuera necesario, DirectShow agregará y conectará filtros adicionales de manera totalmente transparente.
Los datos definitivos de tamaño de la imagen se devuelven al programa llamador en los parámetros de salida pnWidth y pnHeight, los podemos obtener del filtro SampleGrabber mediante el interfaz ISampleGrabber en una estructura VIDEOINFOHEADER:
hr = g_pIBaseFilterSampleGrabber->QueryInterface(IID_ISampleGrabber,
(LPVOID*)&pGrabber);
Por último, ya solo nos queda iniciar la captura en el canal mediante la función Run del interfaz IMediaControl:
hr = g_pMediaControl->Run();
Reproducción de un archivo de vídeo
En el caso de que queramos reproducir un archivo de vídeo, solo cambia el filtro que vamos a utilizar como fuente de vídeo, que en lugar de un dispositivo será un archivo. Esto lo indicamos con la función AddSourceFilter del objeto FilterGraph, en la cual indicaremos la ruta del archivo:
hr = g_pGraphBuilder->AddSourceFilter(wFileName, L"VideoFile",
&g_pIBaseFilterVideoFile);
En este caso, en lugar de utilizar la función RenderStream para construir el canal, vamos a realizar la conexión de los pines de forma manual. En primer lugar obtenemos el pin de salida del archivo con la función GetPin:
IPin* pOutFile = GetPin(g_pIBaseFilterVideoFile, PINDIR_OUTPUT, 1);
Y el pin de entrada del filtro SampleGrabber de la misma forma:
IPin* pInGrabber = GetPin(g_pIBaseFilterSampleGrabber, PINDIR_INPUT, 1);
Y conectaremos ambos pines usando la función Connect del objeto FilterGraph:
hr = g_pGraphBuilder->Connect(pOutFile, pInGrabber);
Lo mismo para conectar la salida del filtro SampleGrabber con la entrada del filtro NullRenderer. El resto del proceso es igual que en el caso anterior.
Aplicación DirectShowDemo
Por último, vamos a ver cómo utilizar esta librería de funciones desde una aplicación. En el proyecto original, existe una librería de clases que encapsula las llamadas a estas funciones y que podemos utilizar desde cualquier aplicación. Esta es la forma correcta de hacerlo, pero yo he extraído el código básico de esta librería y lo he incluido dentro de la aplicación para simplificar lo más posible el proyecto.
Como la librería de funciones no es un ensamblado nativo de la .NET framework, no podemos enlazarla directamente como una referencia del ejecutable, así que la copio en el directorio de resultados del proyecto mediante un evento de compilación. La clase NativeMethods importa las funciones de esta librería para que puedan ser llamadas desde un ensamblado .NET. Estas son las funciones básicas:
- VideoCamRefreshCameraList: Crea la lista de dispositivos de captura de vídeo conectados en el sistema.
- VideoCamGetCameraDetails: Con esta función obtenemos el objeto IMoniker que representa un dispositivo y su nombre.
- VideoCamInitialize: Realiza las labores de inicialización necesarias (en este caso no realiza ninguna acción).
- VideoCamDisplayCameraPropertiesDialog: Muestra el cuadro de diálogo correspondiente al dispositivo seleccionado con las opciones de configuración.
- VideoCamStartCamera: Inicia la captura y reproducción de un dispositivo.
- VideoCamStartVideoFile: Inicia la captura y reproducción de un archivo de vídeo.
- VideoCamStopCamera: Detiene la reproducción en curso y libera los recursos.
- VideoCamCleanup: Libera todos los recursos reservados antes de finalizar.
La clase que encapsula la cámara es Camera y para los archivos de vídeo existe la clase VideoFile derivada de la anterior. Cada objeto Camera contiene un objeto de clase CameraMethods que es la encargada de realizar las llamadas a la librería. La clase CameraService implementa la lista de cámaras disponibles. Por último, el interfaz IFrameSource encapsula los métodos para iniciar y detener la reproducción y la función callback que se llama cada vez que hay un nuevo frame disponible.
En cuanto al programa principal, lo más destacable, para no alargar más este artículo, es el tratamiento de los datos capturados en la función callback OnReadImage. Estos datos se pasan en el parámetro data, mientras que las dimensiones de la imagen se pasan en una estructura Size en el parámetro sz.
Simplemente creamos un Bitmap y obtenemos un puntero a los bytes que componen la imagen. En un Bitmap cada píxel está representado por 32 bits, 8 para cada uno de los canales rojo, verde y azul más 8 para el canal alfa de transparencia. Los datos que nos proporciona el canal de vídeo no tienen este canal alfa, sino que están compuestos únicamente con los 24 bits de color, por lo que tenemos que añadir nosotros el canal alfa para completar el Bitmap. Para finalizar, pasamos la imagen a un control PictureBox para visualizarla.
El uso de código unsafe es necesario, ya que trabajamos con punteros, por lo que es necesario compilar el proyecto con la opción permitir código no seguro.
Como en el artículo anterior, os dejo la referencia a un par de libros donde podéis encontrar un desarrollo mucho más detallado de la programación con DirectShow: