1 2 3 4 5 6 7 Universidad De Almería 8 Escuela Politécnica Superior Titulación: Ingeniero en Informática 9 Compresión Progresiva de Vídeo en Internet. Alumno: Sergio García Sanz. Almería, 28/05/2002 Directores: Vicente González Ruiz. Leocadio González Casado. Introducción. Objetivos de la memoria. Plan de trabajo y organización de los apartados. A continuación se detalla el plan de trabajo seguido a lo largo de los meses que ha durado el proyecto. Esta organizado en varios capítulos que muestra como ha ido evolucionando hasta conseguir el resultado final. Capitulo 0: Breve descripción del software de transmisión de video que existen en la actualidad. Capitulo 1. En este capítulo detallaremos la base teórica de los algoritmos usados para la compresión y descompresión del video progresivo. Capitulo 2. En este capítulo detallaremos en profundidad el trabajo realizado. Se explicará como se ha desarrollado el software servidor y el software cliente de video progresivo. Capitulo 3. En este capítulo detallaremos las distintas simulaciones realizadas con el software desarrollado teniendo en cuenta los distintos factores que afectan al rendimiento. Capitulo 4. Se detallarán las posibles ampliaciones al proyecto desarrollado. Capitulo 2. Explicación del sistema desarrollado. En este apartado se describirá todo el trabajo realizado hasta conseguir el software cliente y servidor de video progresivo. Durante los meses nos hemos ido planteando objetivos que hemos ido cumpliendo hasta la finalización del proyecto. Estos objetivos los vamos a ir describiendo en los distintos puntos de que consta este capitulo. Introducción. La idea principal con la que partimos a la hora de desarrollar el proyecto era la de implementar un software cliente-servidor de video progresivo que fuese capaz de adaptarse al ancho de banda de la red o incluso a las exigencias de la máquina cliente donde el software iba a ejecutarse. Por lo tanto tendríamos un software corriendo en una máquina (servidor de video progresivo) que escucharía peticiones de los clientes, los cuales solicitan videos comprimidos, que a la llegada se irán descomprimiendo y mostrando por pantalla. Una de las características del algoritmo de descompresión es que cumple a la perfección con esas dos características, es decir no nos importa el ancho de banda que nos proporcione la red, el algoritmo se comporta de forma correcta y es capaz de abandonar el trabajo en un momento dado. Teníamos una idea general de lo que tenía que hacer nuestro software cliente, esta idea estaba basada en un pipeline de tres etapas por donde el flujo del programa tenía que ir avanzando en el tiempo. Estas tres etapas generales eran: Etapa 1: Lectura de los datos y descompresión. Etapa 2: Transformada inversa del volumen. Etapa 3: Visionado de las imágenes. | |Sg 1 |Sg 2 |Sg 3 |Sg 4 | | |Etapa 1 |L/D 1 |L/D 2 |L/D 3 |L/D 4 |... | |Etapa 2 | |T 1 |T 2 |T 3 |T 4 | |Etapa 3 | | |V 1 |V 2 |V 3 | Siendo: L: (lectura de datos). D: Decodificación de los datos. T: Transformada inversa. V: Visionado de los datos. Por lo tanto en un momento dado del programa estaríamos trabajando con datos de hasta tres segundos distintos, es decir, tendríamos una latencia de dos segundos, ya que al tercer segundo empezaríamos a ver el video por la pantalla. Por ahora no me preocupa indicar cual es la información que entra y sale de cada una de las etapas del pipeline porque mas adelante se detallarán en profundidad. Teniendo en la cabeza la idea general de como tenía que comportarse el software cliente de video progresivo empezamos a trabajar. Desarrollo de un visor de video. Necesitábamos disponer de un software que fuese capaz de reproducir volúmenes de imágenes (conjunto de imágenes planas una detrás de otra) a una velocidad de N imágenes por segundo, para ello usamos la librería Xlib que nos permite trabajar con el entorno X de forma relativamente sencilla. La rutina es muy sencilla de comprender. Como entrada tendría una matriz de dos dimensiones (que sería un fotograma del volumen completo), el tamaño de las filas y de las columnas. Con estos datos de entrada se genera una ventana en entorno X, con la altura y la anchura indicada en los parámetros y que cada uno de los puntos que forma la imagen son obtenidos de la matriz que se le pasa como argumento. Ejemplo de cómo se representa un fotograma. for(i=0;i void (*signal (int sig, void (*action) ())) (); Sig es el número de la señal sobre la que queremos especificar la forma de tratamiento. Action es la acción que queremos que se inicie cuando se reciba la señal. Action puede tomar tres tipos de valores: SIF_DFL: Indica que la acción a realizar cuando se recibe la señal es la acción por defecto asociada a la señal. SIG_IGN: Indica que la señal se debe ignorar. Dirección: Es la dirección de la rutina de tratamiento de la señal (manejador suministrado por el usuario). La declaración de esta función debe ajustarse al siguiente modelo. #include void handler (sig [, code, scp]) int sig, code; struct sigcontext *scp; Cuando se recibe la señal sig, el núcleo es quien se encarga de llamar a la rutina handler pasándole los parámetros sig, code y scp. Sig es el número de la señal, code es una palabra que contiene información sobre el estado del hardware en el momento de invocar a handler y scp contiene información de contexto definida en . Tanto code como scp son parámetros opcionales y dependientes de la arquitectura de nuestra máquina. Un ejemplo de utilización de signal: if (signal(SIGALRM, &ProcesoVisor) == SIG_ERR) { fprintf(stderr,"No puedo registrar el manejador de señales para SIGALRM\n"); } Setitimer. Esta llamada se utiliza para controlar los temporizadores que hay definidos por cada proceso. Su declaración es la siguiente: #include setitimer (int which, struct itimerval *value, struct itimerval *ovalue) Los valores que puede tomar which son: ITIMER_REAL: Temporizador en tiempo real. ITIMER_VIRTUAL: Temporizador en tiempo virtual, solo contabiliza el tiempo mientras el proceso está ejecutando y no cuando duerme. ITIMER_PROF: Temporizador de perfilado. Se usa al crear perfiles de un proceso. Setitimer se utiliza para definir el valor del temporizador especificado por which. Value es un puntero a una estructura con los nuevos valores del temporizador y ovalue es un puntero a una estructura donde se devuelven los antiguos valores del temporizador. La estructura itimerval se define como sigue: Struct itimerval { struct timeval it_interval; struct timeval it_value; }; Siendo it_interval el intervalo del temporizador y it_value el valor actual del temporizador. La estructura timeval se define así: Struct timeval { unsigned long tv_sec; long tv_usec; }; Siendo tv_sec los segundos transcurridos desde el dia 1 de Enero de 1970 y tv_usec indica microsegundos. Su rango está comprendido entre el 0 y 999.999. Un ejemplo del uso de setitimer: /* valores de temporizacion para la señal */ itimer.it_interval.tv_usec =i_sec; itimer.it_interval.tv_sec = 0; itimer.it_value.tv_usec = i_sec; itimer.it_value.tv_sec = 0; setitimer(ITIMER_REAL, &itimer, NULL); Tratamiento de regiones críticas o mutex. Entre las funciones utilizadas tenemos: pthread_mutex_init. Inicializa el semáforo para poder ser utilizado. POSIX define distintos atributos que modifican el comportamiento de los semáforos. Por defecto el mutex no utilizará ningún tipo de herencia de prioridades. Su definición es la siguiente: pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr) Siendo mutex un puntero a un parámetro del tipo pthread_mutex_t, que es el tipo de datos que usa la librería Pthreads para controlar los mutex. Attr es un puntero a una estructura del tipo pthread_mutexattr_t, y sirve para definir qué tipo de mutex queremos: normal, recursivo o errorcheck. Si este valor es NULL (recomendado), la librería le asignará un valor por defecto. La función devuelve 0 si se pudo crear el mutex o -1 si hubo algún error. pthread_mutex_lock. Esta función pide el bloqueo para entrar en una RC (región crítica). Si queremos implementar una RC, todos los threads tendrán que pedir el bloqueo sobre el mismo semáforo. La definición es: int pthread_mutex_lock(pthread_mutex_t *mutex) Siendo mutex un puntero al mutex sobre el cual queremos pedir el bloqueo o sobre el que nos bloquearemos en caso de que ya haya alguien dentro de la RC. Como resultado, devuelve 0 si no hubo error, o diferente de 0 si lo hubo. pthread_mutex_unlock. Esta es la función contraria a la anterior. Libera el bloqueo que tuviéramos sobre un semáforo. La definición es: int pthread_mutex_unlock(pthread_mutex_t *mutex) Siendo mutex el semáforo donde tenemos el bloqueo y queremos liberarlo. Retorna 0 como resultado si no hubo error o diferente de 0 si lo hubo. Tratamiento de procesos. Fork. La única forma de crear un proceso en el sistema UNIX es mediante la llamada fork. El proceso que invoca a fork se llama proceso padre y el proceso creado es el proceso hijo. La declaración es la siguiente: #include pid_t fork(); La llamada a fork hace que el proceso actual se duplique. A la salida de fork, los dos procesos tienen una copia idéntica del contexto del nivel de usuario excepto el valor de pid, que para el proceso padre toma el valor del PID del proceso hijo y para el proceso hijo toma el valor 0. Si la llamada a fork falla, devolverá el valor -1 y en errno estará el código del error producido. Tratamiento de sockets. Socket. La llamada para abrir un canal bidireccional de comunicaciones es socket y se declara como sigue: #include #include int socket (int af, int type, int protocol); Socket crea un punto terminal para conectarse a un canal y devuelve un descriptor. El descriptor del conector devuelto se usará en llamadas posteriores a funciones de la interfaz. Af (address family) especifica la familia de conectores o familia de direcciones que se desea emplear. Las distintas familias están definidas en el fichero de cabecera . Las dos familias siguientes suelen estar presentes en todos los sistemas: AF_UNIX: Protocolos internos UNIX. Es la familia de conectores empleada para comunicar procesos que se ejecutan en una misma máquina. AF_INET: Protocolos Internet. Es la familia de conectores que se comunican mediante protocolos, tales como TCP o UDP. El argumento type indica la semántica de la comunicación para el conector. Puede ser: SOCK_STREAM: Conector con un protocolo orientado a conexión. SOCK_DGRAM: Conector con un protocolo no orientado a conexión o datagrama. Protocol especifica el protocolo particular que se va a usar con el conector. Si la llamada se ejecuta satisfactoriamente, devolverá un descriptor de fichero válido. En caso contrario, devolverá -1 y en errno estará codificado el error producido. Bind. La llamada bind se utiliza para unir un conector con una dirección de red determinada. Su declaración es la siguiente. #include /* Para familia AF_UNIX */ #include /* Para familia AF_INET */ #include int bind (int sfd, const void *addr, int addrlen); Cuando se crea un conector con la llamada socket, se le asigna una familia de direcciones, pero no una dirección particular. Bind hace que el conector cuyo descriptor es sfd se una a la dirección de conector especificada en la estructura apuntada por addr. Addrlen indica el tamaño de la dirección. Listen. Cuando se abre un conector orientado a conexión, el programa servidor indica que está disponible para recibir peticiones de conexión mediante la llamada a listen, que se declara como sigue: int listen (int sfd, int backlog) La llamada a listen la suele ejecutar el proceso servidor después de las llamadas a socket y bind. Listen habilita una cola asociada al conector descrito por sfd. La longitud de esta cola es la especificada en el argumento backlog. Para que la llamada a listen tenga sentido, el conector debe ser de tipo SOCK_STREAM (orientado a conexión). Connect. Para que un proceso cliente inicie una conexión con un servidor a través de un conector, es necesario que haga una llamada a connect. Esta función se declara de la siguiente forma: #include /* familia AF_UNIX */ #include /* familia AF_INET */ #include int connect (int sfd, const void *addr, int addrlen); sfd es el descriptor del conector que da acceso al canal y addr es un puntero a una estructura que contiene la dirección del conector remoto al que queremos conectarnos. Addrlen es el tamaño en bytes de la dirección. Accept. Los procesos servidores van a leer peticiones de servicio mediante la llamada accept. La declaración de esta llamada se muestra a continuación: #include int accept (int sfd, void *addr, int *addrlen); Esta llamada se usa con conectores orientados a conexión, como el tipo SOCK_STREAM. El argumento sfd es un descriptor del conector creado por una llamada previa a socket y unido a una dirección mediante bind. Accept extrae la primera petición de conexión que hay en la cola de peticiones pendientes creada con una llamada previa a listen. El argumento addr debe apuntar a una estructura local con la dirección del conector. La llamada accept rellenará esa estructura con la dirección del conector remoto que pide la conexión. El argumento addrlen debe ser un puntero a int. Close. Una vez que un proceso no necesita realizar más accesos a un conector, puede desconectarse del mismo. Para ello podemos usar la orden close. Esta llamada va a cerrar el conector en sus dos sentidos. Htonl. Función que convierte datos unsigned long del formato que maneja el ordenador al formato que maneja la red. La definición es: Unsigned long htonl (unsigned long hostlong); Ntohl. Función que convierte datos unsigned long del formato que maneja la red al formato que maneja el ordenador. La definición es: Unsigned long ntohl (unsigned long netlong); Select. Permite interrogar al sistema sobre el estado de varios dispositivos y devuelve cuáles están listos para leer datos de ellos y cuáles lo están para escribir en ellos. La declaración de select es la siguiente: #include int select (int nfds, int readfds, int writefds, int exceptfds, struct timeval *timeout); Select examina los descriptores de fichero especificados en las máscaras de bits readfds, writefds y exceptfds. El significado de cada máscara es el siguiente: Readfds representa los descriptores de los ficheros de lectura por los que preguntamos. Writefds representa los descriptores de los ficheros de escritura que preguntamos. Exceptfds representa los descriptores de los ficheros con alguna condición especial por los que preguntamos. Si alguna de las condiciones anteriores no nos interesa, lo indicaremos pasándole a select el argumento 0. Los argumentos se pasan por referencia, esto significa que select los va a modificar de acuerdo con el estado de los ficheros que interroga. Timeout es un puntero no nulo que indica el tiempo máximo que se va a esperar desde que select entra en ejecución hasta que devuelve el control. Select puede devolver el control bien porque alguno de los ficheros interrogados cumpla los requisitos pedidos o bien porque el tiempo de espera especificado en timeout expire. Si timeout es un puntero a NULL, la llamada esperará a que se dé algún cambio de estado en alguno de los ficheros interrogados. Las máscaras de bits que codifican los números de los descriptores agrupan 32 posibles descriptores por cada numero entero. Si queremos interrogar por ficheros con un descriptor más alto, hay que utilizar un array de enteros. Para encapsular estos detalles, junto con select se facilita un conjunto de 4 macros para manejar variables de ese tipo. Estas macros se definen con la siguiente interfaz: FD_ZERO (fd_set *fdset): Pone a cero los bits de "fdset". FD_SET (int fd, fd_set *fdset): Activa en fdset el bit correspondiente al descriptor fd. FD_CLEAR (int fd, fd_set *fdset): Desactiva en fdset el bit correspondiente al descriptor fd. FD_ISSET (int fd, fd_set *fdset): Comprueba en fdset el bit correspondiente al descriptor fd. Tratamiento de la memoria compartida. Shmget. Esta función devuelve el identificativo del segmento de memoria compartida asociado al valor del argumento key. Su declaración es: #include #include #include int shmget (key_t key, int size, int shmflg); key es el identificativo del área de la memoria compartida. Size es el tamaño en bytes de la zona de memoria que queremos crear. Shmflg es una máscara de bits que está compuesta por los siguientes valores: IPC_CREAT: para crear un nuevo segmento. Si este indicador no se usa, shmget() encontrará el segmento asociado con key, comprobará que el usuario tenga permiso para recibir el shmid asociado con el segmento, y se asegurará de que el segmento no esté marcado para destrucción. IPC_EXCL: Usado con IPC_CREAT para asegurar el fallo si el segmento ya existe. Mode_flags (9 bits mas bajos): Especifican los permisos otorgados al dueño, grupo y resto del mundo. Shmat y Shmdt. Antes de usar una zona de memoria compartida, tenemos que asignarle un espacio de direcciones virtuales de nuestro proceso. Esto es lo que se conoce como unirse o atarse al segmento de memoria compartida. Una vez que dejamos de usar un segmento de memoria, tenemos que desatarnos de él. Al realizar esta operación, el segmento deja de estar accesible para el proceso. Sus definiciones son: #include #include #include char *shmat (int shmid, char *shmaddr, int shmflg); int shmdt (char *shmaddr); Shmid es el identificador de una zona de memoria creada mediante una llamada previa a shmget. Shmaddr es la dirección virtual donde queremos que empiece la zona de memoria compartida. Shmflg es una máscara de bits que indica la forma de acceso a la memoria. Shmctl. Con shmct realizamos operaciones de control sobre una zona de memoria previamente creada por una llamada a shmget. Su declaración es: #include #include #include int shmctl (int shmid, int cmd, struct shmid_ds *buf) Shmid es un identificador válido devuelto por una llamada previa a shmget. Cmd indica el tipo de operación a realizar. Entre las más importantes está: IPC_RMID: Borra del sistema la zona de memoria compartida identificada por shmid. Si el segmento está unido a varios procesos, el borrado no se hace efectivo hasta que todos los procesos liberen la memoria. Tratamiento de la X Window XOpenDisplay. Se usa para establecer una conexión entre un programa de aplicación y un servidor de display. Su definición es: Display *XOpenDisplay (char *nombreDisplay) Donde nombreDisplay es una cadena con el formato: nombreHost:numero.numeroPantalla. nombreHost es el nombre del host en el que reside el servidor. El numero es el número del display, y el 'numeroPantalla' es el numero de la pantalla. El 'nombreDisplay' puede ser NULL, en cuyo caso se usará por la cadena almacenada en la variable de entorno DISPLAY. XCloseDisplay. Sirve para cerrar la conexión con el display. Su definición es la siguiente: XCloseDisplay (Display *display); Cierra la conexión con el servidor de display especificado y destruye todas las ventanas, todos los Ids de los recursos creados por el programa. DefaultScreen. Devuelve el número asociado a la pantalla por defecto en referencia a un display abierto mediante XopenDisplay. Su definición es la siguiente: DefaultScreen (display); Este número de pantalla se utilizará en llamadas a otras funciones, por lo que es aconsejable que sea almacenado en una variable global. XCreateSimpleWindow. Se utiliza para crear una ventana asociada a un display. Su definición es la siguiente: Window XcreateSimpleWindow (Display *display, Window madre, int x, int y, unsigned int anchura, unsigned int altura, unsigned int anchuraborde, unsigned long colorborde, unsigned long colorfondo); El display especifica la conexión con el servidor. La madre especifica la ventana madre donde se creará la ventana. Se pueden usar las macros RootWindow o DefaultRootWindow. XMapWindow. Mapea la ventana especificada. Su definición es la siguiente: XmapWindow (Display *display, Window window); XSelectInput Durante la inicialización de nuestra aplicación y creación de la ventana principal de la misma es necesario indicarle al servidor qué tipo de eventos se desean recibir, pues puede interesarnos no recibir información de algún tipo de evento ocurrido, mientras que otro pueda ser vital para nuestra aplicación. Para la indicación de los eventos deseados al servidor X se utiliza la función XselectInput y las llamadas máscaras de eventos. La definición de la función es la siguiente: XselectInput (display, window, event_mask); El argumento display es la variable que conecta nuestro cliente con el servidor X, la variable window es el identificador de la ventana sobre la que deseamos seleccionar los eventos que se recibirán, y event_mask es una variable máscara. Cada bit de esta variable se refiere a un evento concreto. Para hacer más fácil la selección de eventos se usan unas cadenas o nombres simbólicos que identifican a los eventos. Los principales son : ButtonPressMask, ButtonReleaseMask, KeyPressMask, KeyReleaseMask, ButtonMotionMask, EnterWindowMask, LeaveWindowMask, etc. XNextEvent Recoge el siguiente evento de la cola en modo bloqueante, es decir, recoge cualquier evento de cualquier ventana de nuestra aplicación y lo introduce en la estructura XEvent que se le pasa como parámetro. Su definición es la siguiente: XNextEvent (Display *display, XEvent *evento); La estructura XEvent es el paquete de información que el servidor envía al cliente cuando ocurre el evento. XCreateGC Un contexto gráfico o simplemente GC, es un recurso del servidor donde se almacenan los atributos de los gráficos. Es necesario al menos un GC para que el servidor acepte peticiones de primitivas gráficas por parte del cliente, ya que cada petición de primitiva gráfica debe especificar el identificador del GC que se debe usar. Los GCs se crean y se almacenan en el servidor. Para pintar gráficos es necesario: Crear un GC y obtener su ID. Establecer los atributos del GC apropiadamente. Enviar las peticiones de las primitivas gráficas. La definición para crear un contexto gráfico es la siguiente: GC XcreateGC (Display *display, Drawable drw, unsigned long mascara, XGCValues *valores); XcopyArea La definición de esta función es la siguiente: XcopyArea (Display *display, drawable src, drawable dest, GC gc, int src_x, int src_y, unsigned int width, unsigned int height, int dest_x, int dest_y); Esta función copia un área cuadrada desde el drawable src al drawable dest. Ambos han de tener la misma profundidad de color. La principal ventaja de trabajar con estructuras XImage consiste en que éstas se almacenan localmente en el cliente haciendo que no tengan que pasar por la red.