https://github.com/johnny-godoy/laboratorios-mds
¶Numpy
para manejo de datos en arreglos/tensores.numpy
con respecto a trabajar en Python 'puro'.for
puede afectar en la eficiencia en al procesar datos masivos.El laboratorio deberá ser desarrollado sin el uso indiscriminado de iteradores nativos de python (aka "for", "while"). La idea es que aprendan a exprimir al máximo las funciones optimizadas que nos entrega numpy
, las cuales vale mencionar, son bastante más eficientes que los iteradores nativos sobre arreglos.
El lab estará basado en algunos conceptos básicos de procesamiento de imágenes, por lo que te iremos guiando, paso a paso por cada uno de los tópicos a desarrollar.
En Data Science son múltiples las aplicaciones que exigen el uso exhaustivo de listas de varias dimensiones. Estas entidad reciben formalmente el nombre de arreglos o tensores.
Pensemos en que queremos almacenar objetos en un casillero común y corriente: podemos pensar que este puede ser representado por una matriz de dos dimensiones: alto y ancho. ¿Que sucederá si este casillero nos queda pequeño y queremos guardar más información del mismo tipo?: La solución es simple es agregar otro casillero. Esto puede ser pensado como el aumento de la dimensión de nuestro objeto, pasando a ser ahora (alto, ancho, id casillero). Esto no es otra cosa que un tensor de 3 dimensiones.
Una imagen es una representación visual de una matriz que contiene de números que describen intensidades de color (llamados píxeles). Esto visto desde la perspectiva de una imagen en blanco y negro, vendria siendo una matriz que reune las diferentes intensidades de los pixeles desde 0 a 255.
Cuando las imágenes poseen colores, las imágenes vendrían siendo "sin querer queriendo", una bella representación de lo que es un tensor: estas pueden ser representadas por un tensor de 3 dimensiones que les dan el ancho, alto y el canal, en donde son alojados los colores de la imagen.
Como pueden ver, la imagen puede ser interpretada como un tensor de tres dimensiones(un ancho, un alto y la intensidad de cada color) en el a cada posición $(i,j)$ de la imagen, le asociaremos 3 intensidades de colores RGB (Rojo, Verde y Azul). Estas intensidades irán desde el $0$ al $255$. La combinación de estos 3 canales nos permitirá representar gran parte de los colores que encontramos en la naturaleza.
Instalar paquetes: Si están usando pip
import sys
# !{sys.executable} -m pip install pillow plotly imageio
# En este lab usaremos adicionalmente pillow, una estupenda librería
# para manejar imágenes.
# https://pillow.readthedocs.io/en/stable/
from PIL import Image
import numpy as np
import plotly.express as px
foto = np.array(Image.open("./images_lab/cobija.PNG").convert("RGB"))
# Solo para ejemplificar, usaremos plotly (NO USARLO EN LO QUE QUEDA DEL LAB).
# Pero en el restro del laboratorio, matplotlib debe ser usado
fig = px.imshow(foto)
fig.show()
Luego, llamando la variable donde alojamos el array podemos ver los valores que componen a esta imagen.
print(f'Número de dimensiones: {foto.ndim}')
print(f'Número de elementos por dimensión: {foto.shape}')
Número de dimensiones: 3 Número de elementos por dimensión: (470, 597, 3)
Finalmente visualizamos de forma aleatoria los pixeles de cada canal para mostrar sus intensidades.
print(f"Ejemplo de pixel (10, 200) en el canal 0 - Red: {foto[10, 200, 0]}")
print(f"Ejemplo de pixel (10, 200) en el canal 1- Green: {foto[10, 200, 1]}")
print(f"Ejemplo de pixel (10, 200) en el canal 2- Blue: {foto[10, 200, 2]}")
Ejemplo de pixel (10, 200) en el canal 0 - Red: 97 Ejemplo de pixel (10, 200) en el canal 1- Green: 70 Ejemplo de pixel (10, 200) en el canal 2- Blue: 48
Con lo anterior, suponiendo que la imagen del "gatito" tiene una altura igual a 600 y un ancho de 400, el tensor $G$ que representa a la imagen vendrá dado por $G[600, 400, 3]$.
Luego, si queremos complejizar aún mas esto y queremos tener tensores que agrupen un conjunto de imágenes (de igual tamaño) tendremos lo siguiente:
Este conjunto de imágenes nos generará la necesidad de producir una nueva dimensión, esto producto que las dimensiones son los espacios donde alojamos la información, por esto al conjunto de imágenes le agregaremos una dimensión que identifica cada una de las imágenes del conjunto, quedando representada por el tensor $G[0:n_d, 600, 400, 3]$. Por lo general, cuando tenemos imágenes con dimensionalidad 4 es porque se tratan de videos, o sea una secuencia de imágenes; el caso se complejiza aún más cuando agregamos sonido y esto se va a las pailas.
Dato: La representación que posee cada una de las dimensiones puede cambiar dependiendo de la librería utilizada, en pytorch por ejemplo las dimensiones de una imagen vienen dadas por [batch, canales, alto, ancho] y no [batch, alto, ancho, canales] como en numpy.
Para el caso de imágenes podemos encontrar múltiples aplicaciones con la manipulación de los tensores y operando matemáticamente con ellos. Algunas de las aplicaciones más conocidas (y que aplicaremos) son las siguientes:
Pasar a escala de grises una imagen: Los valores RGB se convierten a escala de grises mediante la fórmula NTSC:
$$ imagen\_gris = 0.299 * Rojo + 0.587 * Verde + 0.114 * Azul $$
Esta fórmula representa la percepción relativa de la persona promedio del brillo de la luz roja, verde y azul.
Mejora de contraste: Son múltiples las técnicas que nos permiten mejorar el contraste de una imagen, pero, una técnica simple para modificar los contrastes consta en obtener un factor de corrección llamado F en base al contraste deseado (C). Luego, es aplicado en la diferencia entre la imagen y 128. De esta forma obtenemos R, que es la imagen con la mejora de contraste deseada.
$$ F=259*(C+255)/(255*(259-C)) $$ $$ R=F*(img-128)+128 $$
En base a lo explicado y visto en clases, a continuación, deben construir cada uno de los programas solicitados en las actividades señaladas más abajo. Estás, deben ser desarrollados de forma grupal (2 personas por grupo) y, la solución no debe ser compartida con personas externas al grupo; si se detecta que dos grupos entregan el mismo trabajo, será considerado plagio y se tomaran medidas al respecto.
# Libreria Core del lab.
import numpy as np
from pathlib import Path
# Librerias para graficar
import matplotlib.pyplot as plt
# Nota: Utilizar solo matplot para este lab. NO USAR PLOTLY,
# ya que tiene problemas de compatibilidad con imagenes
# Funcionalidades dependientes del Sistema Operativo.
import os
# Librerias utiles para cargar y generar Gifs
import imageio
from PIL import Image
from scipy.signal import convolve2d
Descomprima el archivo "images_lab.zip" en algún directorio de su computador o plataforma, observen las imágenes y clasifíquenlas a su gusto, para luego en un diccionario cargar y agrupar las diferentes imágenes (no cree mas de tres llaves).
Hecho esto, visualize dos imágenes y verifique la dimensionalidad de estas imágenes con el comando .shape. Comente la dimensionalidad de las imágenes.
Las siguientes celdas de código le permitirá cargar las imágenes que utilizaremos durante este laboratorio.
La primera celda implementa la función from_jpg
, la cual, dado una ruta, carga una imágen:
def from_jpg(path):
ruta = Path(path)
image = np.array(Image.open(ruta), dtype='int')
return image
La segunda celda carga las imágenes y las guarda en un diccionario.
images = {
"gatitos": [
from_jpg("./images_lab/gato1.jpg"),
from_jpg("./images_lab/gato2.jpg"),
from_jpg("./images_lab/gato4.jpg"),
],
"Personas": [
from_jpg("./images_lab/personas.jpg"),
from_jpg("./images_lab/gurus.jpg"),
],
"Monos_chinos": [from_jpg("./images_lab/monitos.jpg")],
}
A continuación, utilice la función def show(imagen)
(definida más abajo) para explorar las imágenes cargadas en la celda anterior.
Respuesta Esperada:
def show(imagen):
plt.imshow(imagen)
plt.show()
x, y, z = imagen.shape
print(f'Dimensiones de la imagen: {x}x{y} (Alto x Ancho)')
show(images['gatitos'][0])
# usar show para mostrar las otras imágenes...
Dimensiones de la imagen: 869x750 (Alto x Ancho)
Ahora que sabemos cómo plotear y cargar una imagen, cree una clase llamada "Imagen" la que cumpla las siguientes características:
__init__
debe comprobar que la imagen es un arreglo de numpy (con isinstance
) y adicionalmente que este tiene 3 dimensiones. En caso contrario, debe levantar excepciones con mensajes correspondientes al error detectado (ustedes definen el mensaje). show()
que muestre la imagen usando la función plt.show()
.info()
que retorna un string con las dimensiones de la imagen.__mul__
, __add__
y __sub__
para realizar operaciones matemáticas entre el objeto y arrays, int o floats. Realice la función pensando que la operación se puede aplicar tanto para izquierda y derecha. Como estamos trabajando con imágenes los outputs deben ser enteros, por esto se le aconseja utilizar .astype(int)
para transformar los arrays de salida a un formato legible por matplotlib.__add__
y __sub__
implementen una saturación de las imágenes. Es decir, la suma o resta deben dar como valor máximo 255 y/o como valor mínimos mayores o iguales a 0.__mul__
deben implementar un método que nos permita saturar las imágenes (es decir que los valores del array no sobrepasen 255) y también no nos permita obtener valores inferiores a cero.Implementadas los métodos, compruebe que la funcionalidad es la correcta mediante la ejecución de los asserts incluidos un par de celdas más abajo.
Notas:
- Pueden reutilizar el código implementado en las celdas anteriores para implementar los métodos
show
einfo
. Sin embargo, No invoquen directamente esas funciones.- La idea es que la imagen contenida en la clase sea inmutable, por ende, todos los metodos que modifiquen la imagen contenida en el objeto deberan retornar un nuevo objeto de la clase
Imagen
que contenga la imagen modificada.
def show(imagen):
plt.imshow(imagen)
plt.show()
x, y, z = imagen.shape
print(f'Dimensiones de la imagen: {x}x{y} (Alto x Ancho)')
def saturar_arreglo(arr):
return np.clip(arr.astype(int), 0, 255)
class DimensionException(ValueError):
pass
class Imagen:
"""Clase contenedora de imágenes"""
def __init__(self, img):
if isinstance(img, np.ndarray):
if img.ndim != 3:
raise DimensionException("El argumento debe ser un arreglo de numpy de solo 3 dimensiones")
if img.shape[-1] != 3:
raise DimensionException(
"El argumento debe ser un arreglo de numpy de solo 3 dimensiones "
"tal que la última dimensión solo tiene 3 canales"
)
self.imagen = img
self.x, self.y, self.z = img.shape # Se agregan atributos que guardan la dimensión.
else:
raise TypeError(
"Debes entregar un arreglo de numpy como argumento del constructor de "
"Imagen"
)
def show(self):
"""Muestra la imágen contenida en el objeto.
Su funcionalidad debe ser igual a la de la función mostrar_imagen.
"""
plt.imshow(self.imagen)
plt.show()
def info(self):
""" Imprime las características de la imagen cargada: Alto y ancho.
"""
print(f'Dimensiones de la imagen: {self.x}x{self.y} (Alto x Ancho)')
def __add__(self, other):
"""Redefine la operación + entre imagen y escalar.
# Idea, usar indexado condicial (similar a los filtros de pandas).
# Sumar y luego que en cada pixel mayor a 255 sea asignado el máximo.
# Ver los tests para mas información.
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return Imagen(saturar_arreglo(self.imagen + other))
def __radd__(self, other):
"""Operación conmutativa de __add__.
Hint: debería llamar a __add__...
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return self + other
def __sub__(self, other):
"""Redefine la operación + entre imagen y escalar.
# Idea, usar indexado condicial (similar a los filtros de pandas).
# Restar y luego que en cada pixel mayor a 255 sea asignado el máximo.
# Caso similar para valores menores a 0, donde debera asignar el minimo a esos pixeles
# Ver los tests para mas información.
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return self + (-other)
def __rsub__(self, other):
"""Operación conmutativa de __sub__.
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return Imagen(saturar_arreglo(other - self.imagen))
def __mul__(self, other):
"""Redefine la operación + entre imagen y escalar.
# Idea, usar indexado condicial (similar a los filtros de pandas).
# Sumar y luego que en cada pixel mayor a 255 sea asignado el máximo y
# cada valor inferior a 0 debe ser 0.
# Ver los tests para mas información.
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return Imagen(saturar_arreglo(self.imagen * other))
def __rmul__(self, other):
"""Operación conmutativa de __mul__.
Hint: debería llamar a __mul__...
Parameters
----------
other : Union[int, np.ndarray]
Escalar o arreglo que será sumado a cada pixel de la imagen
"""
return self*other
Resultados esperados:
gatito = Imagen(images["gatitos"][1])
gurus = Imagen(images["Personas"][1])
# Test show e info.
gatito.show()
gatito.info()
gurus.show()
gurus.info()
Dimensiones de la imagen: 933x700 (Alto x Ancho)
Dimensiones de la imagen: 380x506 (Alto x Ancho)
np.min((1000 - gatito ).imagen)
255
# Tests de los overload de operadores.
# Test __add__
# Idea del test: Todos los elementos de la imagen deben ser a lo más 255.
# Test __add__
assert np.max((gatito + 1000).imagen) == 255
# Test __radd__
assert np.max((1000 + gatito).imagen) == 255
# Test __sub__
assert np.min((gatito - (-1000)).imagen) == 255
# Test __sub__
assert np.max((gatito - 1000).imagen) == 0
# Test __rsub__
assert np.min((1000 - gatito ).imagen) == 255
# Test __mul__ (probar minimo)
assert np.max((-555555 * gatito).imagen) == 0
# Test __mul__ (probar maximo)
assert np.max((555555*gatito).imagen) == 255
# Test __rmul__ (probar minimo)
assert np.max((gatito*-555555).imagen) == 0
# Test __rmul__ (probar maximo)
assert np.max((gatito*555555).imagen) == 255
Ahora que comprenden las diferentes dimensiones que componen a una imagen (en la práctica), ahora realizaremos diferentes tareas de procesamiento de imágenes. Para esto, deben crear una clase llamada "LibImagen
" que cumpla los siguientes requisitos:
Nota 🗒️: Todo método debe tomar una Imagen y retornar una nueva Imagen.
Nota 2: El tipo de datos del arreglo de la imagen que generen o modifiquen debe ser "int". De lo contrario, puede no visualizarse correctamente.
Consiste en recorrer una imagen por cada uno de sus canales utilizando una matriz que lleva por nombre Kernel. El kernel, examinará los conjuntos de pixeles que recorre, aplicando una multiplicación de los valores circundantes ,y sumando todos los valores generados de este producto para generar un nuevo pixel en el tensor de salida.
imagen = gatito.imagen
new_canal = 0.299*imagen[:, :, 0] + 0.587*imagen[:, :, 1] + 0.114*imagen[:, :, 2]
new_canal
array([[ 39.806, 39.806, 38.806, ..., 56.059, 56.287, 52.287], [ 35.806, 35.806, 35.806, ..., 59.102, 60.102, 59.102], [ 34.806, 34.806, 34.806, ..., 58.188, 58.188, 60.188], ..., [ 95.333, 97.333, 99.333, ..., 155.905, 155.677, 155.677], [ 96.333, 98.333, 99.333, ..., 155.905, 155.677, 155.677], [ 98.333, 98.333, 98.333, ..., 155.905, 155.677, 155.677]])
class LibImagen():
"""Clase para realizar tareas de procesamiento de imágenes construidas por la clase Imagen."""
def to_negative(self, img_in):
"""Convierte imagen a negativo.
Parameters
----------
img_in : Imagen
Objeto Imagen que contiene imagen a procesar.
Returns
-------
Imagen
Objeto Imagen con la imagen procesada.
"""
return 255 - img_in
def to_gray(self, img_in):
"""
Transforma una imagen en RGB a la escala de grises.
Parameters
----------
img_in : Imagen
Objeto Imagen que contiene una imagen.
Returns
-------
Imagen
Una que contiene una imagen con 3 canales.
Los 3 canales deben tener los mismos valores.
"""
imagen = img_in.imagen
new_canal = (0.299*imagen[:, :, 0] + 0.587*imagen[:, :, 1] + 0.114*imagen[:, :, 2]).astype(int)
new_imagen = np.dstack((new_canal, new_canal, new_canal))
return Imagen(new_imagen)
def get_channel(self, img_in, channel):
"""Obtiene un canal de un color seteando el resto de los canales en 0.
Parameters
----------
img_in : Imagen
Objeto Imagen que contiene una imagen.
channel : str
Nombre del canal que será seleccionado. Valores posibles: ('r','g' o 'b').
Returns
-------
Imagen:
Objeto Imagen que contiene una imagen con 3 canales.
Solo el canal seleccionado debe tener valores distintos a 0.
"""
colores = {"r": [1, 2], "g": [0, 2], "b": [0, 1]}
new_arr = img_in.imagen.copy()
new_arr[:, :, colores[channel]] = 0
return Imagen(new_arr)
def set_contrast(self, img_in, C):
"""Mejora el contraste de una imagen.
Parameters
----------
img_in : Imagen
Objeto Imagen que contiene una imagen.
C : float
Parámetro que define el ajuste de contraste.
Returns
-------
Imagen
Objeto Imagen que contiene una imagen con 3 canales modificados.
"""
F = 259*(C + 255)/(255*(259 - C))
return F*(img_in - 128) + 128
def conv_channel(self, img_in, kernel):
"""Consiste en recorrer una imagen por cada uno de sus canales utilizando el kernel.
El kernel examinará los conjuntos de pixeles que recorre, aplicando una multiplicación de los valores circundantes,
y sumando todos los valores generados de este producto para generar un nuevo pixel en el tensor de salida.
Parameters
----------
img_in : Imagen
Objeto Imagen que contiene una imagen.
kernel : array_like
Matriz de kernel de la convolución.
Returns
-------
Imagen
Objeto Imagen que contiene una imagen con 3 canales modificados por convolución.
"""
img = img_in.imagen
img_out = []
for i in range(img.shape[-1]):
img_channel = convolve2d(img[:, :, i],
kernel,
mode="same",
boundary="symm")
img_out.append(img_channel)
new_image = np.stack(img_out, axis=2)
new_image[new_image>255], new_image[new_image<0] = 255, 0
return Imagen(new_image.astype(int))
Respuesta Esperada:
gatito = Imagen(images["gatitos"][1])
gatito.show()
lib = LibImagen()
print('Negativo')
lib.to_negative(gatito).show()
print('Grayscale')
lib.to_gray(gatito).show()
print('Selección de Canales')
lib.get_channel(gatito, "r").show()
lib.get_channel(gatito, "g").show()
lib.get_channel(gatito, "b").show()
print('Mejora de Contraste')
lib.set_contrast(gatito, 0).show()
print('Convolución')
kernel = np.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]])
lib.conv_channel(gatito, kernel).show()
Negativo
Grayscale
Selección de Canales
Mejora de Contraste
Convolución
Referencia: https://en.wikipedia.org/wiki/Kernel_(image_processing)
# Convolución
kernel_1 = np.zeros((3, 3))
kernel_1[1, 1] = 1
kernel_2 = -np.ones((3, 3))
kernel_2[1, 1] = 4
kernel_3 = np.ones((3, 3))/3
kernel_4 = np.array([[0, -1, 0],
[-1, 4, -1],
[0, -1, 0]])
kernel_5 = -np.array([[1, 4, 6, 4, 1],
[4, 16, 24, 16, 4],
[6, 24, -476, 24, 6],
[4, 16, 24, 16, 4],
[1, 4, 6, 4, 1]])/512
lib.conv_channel(gatito, kernel_1).show()
lib.conv_channel(gatito, kernel_2).show()
lib.conv_channel(gatito, kernel_3).show()
lib.conv_channel(gatito, kernel_4).show()
lib.conv_channel(gatito, kernel_5).show()
Comente:
Para finalizar, comente que hace (o debería hacer) cada filtro convolucional al aplicarlas a su imagen de ejemplo.
(Escriba aquí su justificación)
El filtro...
1.- El filtro de identidad solamente considera el pixel en el que está parado, y da valor 0 a los vecinos, así que no modifica la imagen.
2.- El filtro de detección de crestas encuentra máximos locales de una función definida en las dimensiones de la imagen. Su propósito es encontrar crestas.
3.- El filtro de ruido de caja promedia todos los vecinos, perdiendo detalle en la imagen.
4.- El filtro Laplaciano detecta bordes de una imagen, pues es una aproximación (por diferencias finitas) de la segunda derivada de la intensidad de los pixeles, así que detecta cambios relevantes.
5.- El filtro de máscara de desenfoque agrega ruido gaussiano a la imagen negativa, para agregarla a la imagen positiva y así reducir el ruido final.
A continuación, deben programar una función que nos permite resaltar los objetos en movimientos de una secuencia de imágenes. Para esta parte del laboratorio, deberá utilizar las imágenes dispuestas en la carpeta secuencia_plaza
del archivo zip subido a material docente.
Primero que todo, cargue la secuencia de imágenes que se encuentran en el directorio. Para esto, se recomienda utilizar el comando os.listdir(dir)
, ya que este le facilitará la carga de un gran número de imágenes (pruebe el comando y vea que sucede).
path = "./secuencia_plaza/"
img_names = os.listdir(path)
imagenes = np.array(list(map(lambda img: np.array(Image.open(path + img)), img_names)))
Para realizar este ejercicio utilizaremos un método super básico para la eliminación de fondo. Para esto sigue la siguiente receta:
$$ imagen\_out = imagen(t) - imagen(t+1) $$
Nota: No es necesario que construya una clase para esta parte.
imagenes_grises = [lib.to_gray(Imagen(imagen)).imagen[:, :, 0]
for imagen in imagenes]
imagenes_grises_diff = [imagen_1 - imagen_2
for imagen_1, imagen_2 in
zip(imagenes_grises, imagenes_grises[1:])
]
def dect_mov(sec_img=imagenes_grises_diff, umbral=30):
return [255*(img > 30).astype(np.uint8) for img in plaza]
Ahora es tiempo de relajarse y ver si nuestro experimento logra resaltar los objetos en movimiento de esta polémica Plaza, para esto solo ejecute el siguiente Código y espere.
plaza = imagenes_grises_diff
imageio.mimsave('plaza.gif', dect_mov(plaza))
imageio.mimsave('plaza_15.gif', dect_mov(umbral=15))
imageio.mimsave('plaza_60.gif', dect_mov(umbral=60))
Ejemplo de resultado esperado:
Eso ha sido todo para el lab de hoy, recuerden que el laboratorio tiene un plazo de entrega de una semana y que los días de atraso no se pueden utilizar para entregas de lab solo para tareas. Cualquier duda del laboratorio, no duden en contactarnos por mail o U-cursos.