Table of Content

Eliminar el fondo estático con OpenCV y python

Publicado por Gluón en 05/12/2019

En el anterior post de openCV vimos como podemos empezar a hacer tratamientos de imagen muy básicos. En esta entrada os voy a explicar como eliminar el fondo de los vídeos, partiendo de una imagen estática de referencia. Va a ser un post muy corto, pero que a mi me hubiese venido muy bien cuando lo necesitaba.

El problema

El escenario que tenemos es este: Tenemos una cámara de vídeo colocada en un sitio fijo y sabemos que el fondo no se va a mover. Además, también sabemos que lo queremos segmentar u obtener del vídeo es las "cosas extrañas" que aparezca, es decir, todo lo que no es fondo.

La solución más sencilla que se me ha ocurrido ha sido obtener una imagen de referencia de fondo y restarla de cada frame del vídeo para así poder segmentar más fácil las cosas nuevas que han aparecido.

A lo largo del post vamos a usar como referencia este pequeño video que he encontrado en internet y que nos viene al pelo. Podéis descargarlo desde: https://pixabay.com/videos/walking-people-city-bucharest-6099/

​ Video original

Obteniendo los datos de la camara

Lo primero que tenemos que hacer es ir preparando el script para simplemente leer la webcam, ajustar algún parámetro y capturar el último fotograma. Es importante, al usar este método, que desactivemos la autoexposición de nuestra cámara ya que no nos interesa que al aparecer más cosas en la imagen, se nos recalcule la exposición. Ya que si esto pasa, el fondo ya no sería exactamente igual y el resto del programa no nos funcionaría.

En la siguiente porción de código podéis ver como hacer la primera parte.

# main.py

import cv2

# Adquirimos la webcam
webcam = cv2.VideoCapture(0)
webcam.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)

while True:
   check , frame = webcam.read()
   if not check:
       break

   # Mostramos el frame capturado
   cv2.imshow("Frame", frame)

   # Si pulsamos 'q' salimos
   key = cv2.waitKey(1)
   if key == ord('q'):
       webcam.release()
       cv2.destroyAllWindows()
       break

Además, para hacer el código más sencillo de seguir e implementar, vamos a separar el módulo que se encargará de realizar todo el procesamiento, en un paquete a parte.

El procesador de imagen

Vamos a definir la clase que se va a encargar de procesar la imagen que recibimos de la cámara. Esta nueva clase se tiene que encargar de:

  1. Recibir los datos de la cámara
  2. Guardar un fotograma como fotograma de referencia para calcular luego la diferencia.
  3. Procesar cada frame y devolver el frame modificado.

Así que vamos a ir creando la estructura de la clase y algunas funciones sencillas. Básicamente lo que hacemos de momento es, cuando se recibe la pulsación de la tecla ‘b’ guardamos el frame actual como frame de referencia. Nada más y nada menos. Así que también necesitamos modificar el main.py para empezar a usar nuestra nueva clase.

# procesador.py

class Procesador:
# Variable interna para guardar el fotograma del fondo
background = None
def __init__(self):
    self.background = None

# Guardamos la variable de fondo
def setBackground(self, image):
    self.background = image

# Funcion para manejar las entradas de teclado
def actuate(self, key, frame):
    if key == ord('b'):
        self.setBackground(frame)
# main.py

import cv2
from procesador import *

# Adquirimos la webcam
webcam = cv2.VideoCapture(0)
webcam.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)

# Creamos nuestro procesador
proc = Procesador()
while True:
   check , frame = webcam.read()
   if not check:
       break

   # Mostramos el frame capturado
   cv2.imshow("Frame", frame)

   # Si pulsamos 'q' salimos
   key = cv2.waitKey(1)
   if key == ord('q'):
       webcam.release()
       cv2.destroyAllWindows()
       break
   else:
       proc.actuate(key, frame)

Obteniendo la diferencia

Como queremos quitar el fondo de nuestra imagen, de forma que obtengamos una máscara que aplicar a nuestra imagen, que nos separe lo nuevo del fondo. Para esto vamos a necesitar dos funciones de openCV:

  1. absdiff: Hace el valor absoluto de la diferencia entre dos array. En nuestro caso vamos a hacer la diferencia entre la imagen de fondo y cada uno de los frames que vayamos procesando. (Doc oficial)
  2. threshold: Esta función, tiene varias formas de funcionar, nosotros vamos a usar la que lleva los valores a los extremos a partir de un nivel. Es decir, si el valor de un pixel es mayor de X lo ponemos a 255 (blanco) y sino a 0 (negro). Para ver el resto de funcionalidades que tiene, echa un vistazo aquí.

Por lo tanto nuestra función de python que hará esto será tan sencilla como: coger la imagen actual y el fondo y convertirlos en blanco y negro, y aplicarles un threshold. De esta manera tendremos una imagen en negro donde los valores sean iguales, o parecidos, entre la imagen actual y el fotograma de referencia. Y zonas blancas donde la diferencia supere un umbral.

class procesador:
    #...
    def procesar(self, image):
        #Esperamos a tener el background
        if np.shape(self.background) == ():
            return image
        #Pasamos las imágenes a blanco y negro
        image_blur = cv2.GaussianBlur(image, (51,51), cv2.BORDER_DEFAULT)
        image_bw = cv2.cvtColor(image_blur, cv2.COLOR_BGR2GRAY)
        backblur = cv2.GaussianBlur(self.background, (51,51), cv2.BORDER_DEFAULT)
        back_bw  = cv2.cvtColor(backblur, cv2.COLOR_BGR2GRAY)

    # Hacemos la diferencia y le aplicamos el threshold
    diff = cv2.absdiff(image_bw ,back_bw )
    ret, mascara = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) 

    cv2.imshow("Mascara", mascara)

#...

El problema ahora es que la diferencia no siempre va a ser tan clara como nos imaginamos. Puede ser que parte de lo nuevo se parezca al fondo, aunque sea en parte, y al hacer la resta nos dé que esa parte es fondo… Además, el ruido puede hacer que aparezcan pixeles sueltos que nos salgan como zonas "nuevas".

Nuestro ejemplo queda tal que así, aquí no tenemos tanto ruido ni pixeles sueltos, aun así os recomiendo seguir los pasos anteriores si vuestro video tiene menor calidad o más ruido.

​ Máscara sin filtrar

Filtrando la máscara

Para ello tenemos que aplicar algunos filtros, el primero para eliminar el ruido, y el segundo para rellenar huecos. Para eliminar ruido, vamos a erosionar la mascara y posteriormente la dilataremos. Por el contrario para rellenar huecos vamos a dilatar la máscara y luego la erosionamos. Aunque para hacerlo todo un poco más fácil vamos a aplicar un filtro Gausiano a nuestra imagen para suavizarla, así los cambios menores se filtrarán más rápido.

class procesador:
    #...
    def procesar(self, image):

    #Esperamos a tener la imagen de fondo
    if np.shape(self.background) == ():
        return image
    #Pasamos las imágenes a blanco y negro
    image_blur = cv2.GaussianBlur(image, (51,51), cv2.BORDER_DEFAULT)
    image_bw = cv2.cvtColor(image_blur, cv2.COLOR_BGR2GRAY)
    backblur = cv2.GaussianBlur(self.background, (51,51), cv2.BORDER_DEFAULT)
    back_bw  = cv2.cvtColor(backblur, cv2.COLOR_BGR2GRAY)

    # Hacemos la diferencia y le aplicamos el threshold
    diff = cv2.absdiff(image_bw ,back_bw )
    ret, mascara = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) 

    cv2.imshow("Mascara", mascara)

    # Eliminamos el posible ruido
    kernel_1 = np.ones((4,4), np.uint8)
    mascara = cv2.erode( mascara, kernel, iterations = 5)
    mascara = cv2.dilate(mascara, kernel, iterations = 2)

    kernel_2 = np.ones((10,10), np.uint8)       
    mascara = cv2.dilate(mascara, kernel, iterations = 20)
    mascara = cv2.erose( mascara, kernel, iterations = 10)

    cv2.imshow("MascaraFiltrada", mascara)

#...

Rellenando huecos

En función de la aplicación que queramos hacer, puede ser que necesitemos rellenar huecos. En mi caso quiero saber las áreas donde hay algo diferente para dentro de ellas hacer una segmentación. Por lo tanto, me interesa que cualquier hueco que haya dentro de las áreas grandes, sea cerrado.

Lo que podemos hacer para eso es sacar el contorno de las áreas y luego dibujarlas. Para ello existe la función drawContours que nos permite, además de dibujar el contorno encontrado, rellenarlo del color que queramos.

def procesar(self, image):

    #Esperamos a tener la imagen de fondo
    if np.shape(self.background) == ():
        return image

    #Pasamos las imágenes a blanco y negro
    image_blur = cv2.GaussianBlur(image, (51,51), cv2.BORDER_DEFAULT)
    image_bw = cv2.cvtColor(image_blur, cv2.COLOR_BGR2GRAY)
    backblur = cv2.GaussianBlur(self.background, (51,51), cv2.BORDER_DEFAULT)
    back_bw  = cv2.cvtColor(backblur, cv2.COLOR_BGR2GRAY)

    # Hacemos la diferencia y le aplicamos el threshold
    diff = cv2.absdiff(image_bw ,back_bw )
    ret, mascara = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) 
    cv2.imshow("Mascara", mascara)

    # Eliminamos el posible ruido
    kernel_1 = np.ones((4,4), np.uint8)
    mascara = cv2.erode( mascara, kernel_1, iterations = 5)
    mascara = cv2.dilate(mascara, kernel_1, iterations = 2)

    kernel_2 = np.ones((10,10), np.uint8)       
    mascara = cv2.dilate(mascara, kernel_2, iterations = 20)
    mascara = cv2.erose( mascara, kernel_2, iterations = 10)

    cv2.imshow("MascaraFiltrada", mascara)

    # Buscamos los contornos exteriores
    cnts = cv2.findContours(mascara, cv2.RETR_EXTERNAL,
        cv2.CHAIN_APPROX_SIMPLE)[-2]

    # Rellenamos los contornos
    cv2.drawContours(mascara, cnts, -1, 255, -1)

    cv2.imshow("MascaraRellena", mascara)       

    # Aplicamos la máscara y devolvemos 
    img_masked = cv2.bitwise_and(image, image, mask=mascara)
    return img_masked

Lo único que falta es llamar a la nueva función desde el bucle principal y mostrar el resultado. Al final, el código completo de los dos ficheros quedaría tal que:

# main.py

import cv2
from procesador import *

# Adquirimos la webcam
webcam = cv2.VideoCapture(0)
webcam.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)

# Creamos nuestro procesador
proc = Procesador()
while True:
    check , frame = video.read()
    if not check:
       break

# Mostramos el frame capturado
cv2.imshow("Frame", frame)

# Procesamos el frame
res = proc.procesar(frame)

#Mostramos el resultado
cv2.imshow("Resultado", frame)

# Si pulsamos 'q' salimos
key = cv2.waitKey(1)
if key == ord('q'):
   webcam.release()
   cv2.destroyAllWindows()
   break
else:
   proc.actuate(key, frame)
# procesador.py

class Procesador:
    # Variable interna para guardar el fotograma del fondo
    background = None

    def __init__(self):
        self.background = None

    # Guardamos la variable de fondo
    def setBackground(self, image):
        self.background = image

    # Funcion para manejar las entradas de teclado
    def actuate(self, key, frame):
        if key == ord('b'):
            self.setBackground(frame)

    def procesar(self, image):

        #Esperamos a tener la imagen de fondo
        if np.shape(self.background) == ():
            return image

        #Pasamos las imágenes a blanco y negro
        image_blur = cv2.GaussianBlur(image, (51,51), cv2.BORDER_DEFAULT)
        image_bw = cv2.cvtColor(image_blur, cv2.COLOR_BGR2GRAY)
        backblur = cv2.GaussianBlur(self.background, (51,51), cv2.BORDER_DEFAULT)
        back_bw  = cv2.cvtColor(backblur, cv2.COLOR_BGR2GRAY)

        # Hacemos la diferencia y le aplicamos el threshold
        diff = cv2.absdiff(image_bw ,back_bw )
        ret, mascara = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) 
        cv2.imshow("Mascara", mascara)

        # Eliminamos el posible ruido
        kernel_1 = np.ones((4,4), np.uint8)
        mascara = cv2.erode( mascara, kernel_1, iterations = 5)
        mascara = cv2.dilate(mascara, kernel_1, iterations = 2)

        kernel_2 = np.ones((10,10), np.uint8)       
        mascara = cv2.dilate(mascara, kernel_2, iterations = 20)
        mascara = cv2.erose( mascara, kernel_2, iterations = 10)

        cv2.imshow("MascaraFiltrada", mascara)

        # Buscamos los contornos exteriores
        cnts = cv2.findContours(mascara, cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE)[-2]

        # Rellenamos los contornos
        cv2.drawContours(mascara, cnts, -1, 255, -1)

        cv2.imshow("MascaraRellena", mascara) 

        # Aplicamos la máscara y devolvemos 
        img_masked = cv2.bitwise_and(image, image, mask=mascara) 
        return img_masked

Como podéis ver, tenemos solo las áreas del vídeo que tienen algo de diferencia con el fondo. De esta manera, podemos aplicar luego máscaras solo dentro de la parte que nos interesa, evitando el posible ruido que nos generaría tener el fondo. Además, en la variable cnts del código tenemos todos los contornos separados, en el caso de que nos hiciera falta tratar cada uno de una manera diferente.

Y en el resultado podemos ver como tenemos a nuestro objeto "extraño" separado del resto de la imagen para trabajar con él:

​ Video original

​ Video resultado

Espero que os haya servido este pequeño tutorial y no dudéis en preguntar cualquier duda que tengáis.