Un poco de thumbnails en Java (y II)

En fin, simplemente para acabar con el capítulo de avatares y thumbnails, comentar que me puse a reflexionar sobre como sería la mejor forma de implementar la gestión de imágenes asociadas a usuarios o avatares (común función para un thumbnail) sobre una aplicación de manera que fuese lo más eficiente posible y facilitase la administración, los backups y minimizase las consultas a la base de datos o al sistema de ficheros. Toda esta reflexión venía de la pregunta de dónde era mejor almacenar los avatares en una aplicación estilo portal web o red social, con miles de usuarios concurrentes y posiblemente terabytes en imágenes. El hilo completo de la discusión está en BarraPunto.

Aunque casi nadie opta por ella oponiendose el frente BBDD vs el frente Sistema de Ficheros, en mi opinión, la mejor solución es híbrida. Posteriormente a mi reflexión vi que en efecto ni más ni menos que un Doctor en Informática había llegado a una solución técnica bastante parecida. Paso a explicarme modificando los puntos en los que discrepo con él:

La imagen original que sube el usuario es convertida a una imagen jpg (formato de más alto ratio de compresión) de 80×80 (sería parametrizable sin duda pero yo no la guardaría tan pequeña ya que a día de hoy las pantallas son cada vez mayores y eso permitiría realizar sobre las imágenes efectos JavaScript más chulos :). En mi opinión el tamaño idóneo sería de 150 * 150 aunque eso conlleve un mayor espacio en bbdd) y se guarda, en una tabla independiente e indexada por el código único del usuario (yo en mi caso incluiría como extra de bonus la fecha de última modificación como más info que se cambiaría cada vez que un usuario suba un nuevo avatar, luego explicaré por qué). Además se marca una variable booleana en la tabla de usuarios para indicar que un usuario tiene avatar.

Cada vez que se necesita un avatar para acompañar una noticia, un comentario, … se recupera, junto de con la información del usuario, el estado de su avatar –verdadero o falso–. Esto no conlleva mayor gasto en consultas ya que esa información pervive en base de datos de usuarios (la misma donde debemos buscar la información de los usuarios).

Si la variable de avatar es falsa, el “src” de la imagen apuntará directamente a la imagen estática por defecto (ni más consultas ni más leches).

Por el contrario, si la variable es verdadera, se verifica si existe en un directorio la imagen con el tamaño deseado si es que las hay de varios. De ser así se indica directamente en el “src” de esa imagen el nombre del fichero –así la consulta no genera ejecución de código servidor ni consultas adicionales a la base de datos–.

Yo también añadiría en tiempo de login y de cambio de avatar la generación de los nuevos avatares siempre que no sea una aplicación de 50.000.000 usuarios claro :p. Esto hará que perdamos tiempo en la pantalla de subir un avatar por ejemplo pero no en la navegación donde para un usuario es más crítico el rendimiento.

Si el fichero no existe, nos encargaremos de obtener la imagen de la bbdd, convertirla al tamaño deseado y guardarla en un fichero, así la próxima vez ya estará disponible y no hará falta hacer consultas “caras” de BLOBs a la tabla de avatares.

Esta forma de hacerlo facilita mucho la gestión, ya que se puede borrar tranquilamente el directorio de imágenes que serán generadas automáticamente cuando sean necesarias. Además de tener la ventaja que los avatares almacenados en la BBDD. Sin duda mi opción es mover todo el trabajo posible de base de datos ya que se encargará posiblemente de muchas otras cosas, pero si aprovechar algunas ventajas como son: que la BD te gestiona la seguridad de forma coherente con el resto de la información, el acceso es transaccional: si a mitad de escritura se va todo al garete, rollback y aquí no ha pasado nada, puedes usar sistemas de replicación para tener tolerancia a fallos manteniendo la coherencia entre la BD y los ficheros. t

Sin embargo, supongo que esto no es la panacea porque si los ficheros en lugar de imágenes tuviesen que formar una estructura(por ejemplo, los ficheros .IFO y .VOB de un DVD), hay bríaque guardar esa estructura en la BD :).

Puestos a ser frikis, no iba a quedarme ahí, así que pensé que pasaría si teníamos un sistema con varios servidores de aplicaciones funcionando como un cluster si supongo que la replicación de las BBDD está solucionada. Mi idea en ese caso, para evitar que Pepito busque su avatar en el servidor1 pero cuando el balanceador de carga le lleve a un hipotético servidor2, hay actualizado en BBDD y tengamos un problema de replicación en sistema de ficheros es incluir la modificación de la última modificación del avataraen la tabla de usuarios. Bien es verdad que pueden surgir reticencias acerca de la idoneidad del isitio, pero si lo hiciésemos en la de avatares perderíamos la gran ventaja de evitarnos bien un join bien otra consulta. Posteriormente, lo que haríamos sería comparar si el fichero existe y la fecha de creación es igual a la última del usuario, no actualizamos. Si no, tocaría volver a crear el fichero para no mostrar avatares obsoletos xD.

Para evitar que la consulta a los ficheros sean muy lentas debido a la búsqueda en directorios muy largos, se crearían subdirectorios por código de usuario, pues eso podría convertirse en un importante cuello de botella. También desestimé (no para imagenes generales, solo avatares) la posibilidad de centralizar en base a un servidor de contenido para evitar más llamadas remotas.

¿Opiniones? ¿Mejoras? ¿Sugerencias?

Mis dos videos estelares de Enjuto Mojamuto

En fin, con la mayoría me parto, pero aquí dejo mis dos preferidos:

Mira quien se queja: Pero qué dejame si eres tu quien has aparecido … xD

El peor día de mi vida: Interneeeeeeeeee ……. !!!

Un poco de thumbnails en Java

Bueno, lo primero es lo primero. Si a alguien no le suena este término, un thumbnail podemos definirlo como “una imagen en miniatura o previsualización (de una fotografía o imagen)”. Muchos sitios web los utilizan para evitar un retardo en la carga de imágenes demasiado grandes.

Es sencillo generar thumbnails usando programas de retoque fotográfico como Photoshop que controlan muchos aspectos de imágenes, lienzos, … pero si queremos integrarlos automáticamente en nuestros desarrollos necesitaremos algún mecanismo que lo haga por nosotros.

Como un compañero me pidió a ver si podía echarle un cable con este tema, me decidí a implementar un sistema de gestión de thumbnails en Java. Mi idea era lograr que las imagenes generadas pudiesen ser salvadas en todos los formatos de imágenes más usados (a citar GIF, JPG, BMP y PNG) y a la par mantuviera una interfaz de entrada estandar Java, uséase clases File, BufferedImage, … de ahi que descartase el utilizar alguna librería que encontré googleando.

Ciertamente estuve a punto de conseguirlo salvo por el pequeño detalle de que el formato GIF y la clase ImageIO no se llevan especialmente bien (temas de licencias parece que resueltos de modo nativo en Java6), pero el resultado es bastante satisfactorio, ya que salvo eso permite utilizar como netrada ficheros de tipo jpg, gif, bmp y png, pudiendo salvar thumbnails de cualquier formato salvo gif claro :) . De todos modos, lo he dejado codificado de tal modo que cuando se resuelva ese tema, sobre la teoría funcionará para todos los formatos de entrada y salida.

Algunos problemas con los que me encontré en el proceso son:

- Los JPG no admiten transparencia mientras que los GIF y los PNG si. Este fue un problema rico rico que consegui salvar utilizando un BufferedImage de puente para transformar modelos de color ARGB a RGB incluyendo la transparencia. Si intento una conversión con pérdida (PNG -> JPEG), realizo un doble buffering con una primera capa opaca y blanca :p.

- Quizás alguno se pregunte por qué no usé el método getScaledInstance() de Image. Dejo un link para más información. En su lugar, opté por la solución más compleja pero más eficiente que es la de usar transformaciones afines.

- Uno de los mayores cristos con los que me enfrenté fue con el suavizado de imágenes que sufrían algunas transformaciones que superaban el factor de escala 0.5 (umbral parametrizable), en cristiano, aquellas que queremos reducirlas por ejemplo más de un 50%. Sobre todo para el formato JPG (que curiosamente era el que mayores ratios de compresión obtenía y el que más me interesaba) las imágenes en ocasiones se pixelizaban. La solución fue aplicar otra transformación geométrica previa (me gustaría citar el blog del que encontré la solución pero no lo recuerdo :( ), en este caso de convolución para hacer que algunos colores de los pixels se “fusionaran” con otros cercanos con colores semejantes. Con esto se consigue aproximadamente un thuimbnail suavizado en JPG listo para un proyecto web de entre 3 y 4 kb para 150 * 150 pixeles, que creo que es bastante óptimo incluso para aplicaciones de elevado tráfico. A partir de ahi todo depende del tamaño de la imagen que quieras crear y del formato elegido.

- La clase en principio está alimentada con unas opciones de renderización por defecto que tienden a la calidad, pero es posible incluirle un map al momento de construirla con las opciones de renderizado que prefiramos.

Y aqui os dejo el código (quito imports por claridad):

ProcesadorImagenes : Clase principal de gestión de thumbnails. Contiene métodos que permiten crear los thumbnails del tamaño que queramos dando alto y ancho máximo y un objeto de tipo File o BufferedImage.

/** Clase que podemos usar para procesar imagenes en el lenguaje Java. */
public class ProcesadorImagenes {

/* Constantes */
/** Umbral a partir del cual aplicaremos filtros de convolucion . */
protected static final double UMBRAL_APLICACION_FILTRO_CONVOLUCION = 0.5;

/** Factor de convolucion que aplicamos para los algoritmos de
* suavizado. */
private static final Double FACTOR_CONVOLUCION_SUAVIZADO = 1.2;

/* Atributos */
/** Opciones de renderizado para las imagenes. */
protected RenderingHints opcionesRenderizadoImagenes;

/** Listado de formatos a los que debe aplicarse convolucion por
* sus perdidas en caso de reducciones muy pronunciadas. */
protected static List<String> listadoFormatosFiltroReduccionRuido;

/* Metodos */
/** Constructor de la clase. Carga opciones de renderizado
* por defecto, tendiendo a la calidad. */
public ProcesadorImagenes() {
cargarOpcionesRenderizadoDefecto();
cargarListadoFormatosReduccionRuido();
}

/** Constructor de la clase.
* @param opcionesRenderizadoPropias Opciones propias de renderizado
*/
public ProcesadorImagenes(Map<Key,Object> opcionesRenderizadoPropias) {
opcionesRenderizadoImagenes = new RenderingHints(opcionesRenderizadoPropias);
cargarListadoFormatosReduccionRuido();
}

/** Metodo que carga el listado de formatos que requieren de reducccion de
* ruido en caso de reducciones sensibles.*/
private void cargarListadoFormatosReduccionRuido() {

// Lo primero es crear el objeto
listadoFormatosFiltroReduccionRuido = new ArrayList<String>();
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_JPEG_MAYUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_JPEG_MINUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_JPG_MAYUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_JPG_MINUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_BMP_MINUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_BMP_MAYUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_WBMP_MINUSC);
listadoFormatosFiltroReduccionRuido.add(CodigosFormatosImagenes.
CODIGO_FORMATO_WBMP_MAYUSC);
}

/** Metodo que analiza si un formato dado como parametro requiere la aplicacion
* de algoritmos de reduccion de ruido si es una reduccion sensible.
* @param formato Formato sobre el que analizamos
* @return Codigo booleano indicando si se da esta condicion
*/
protected Boolean esFormatoRequiereReduccionRuido(final String formato) {

// Busco en mi listado
Iterator<String> it = listadoFormatosFiltroReduccionRuido.iterator();

while (it.hasNext()) {

// Recupero el formato del interior del iterador
String formatoIt = it.next();

// Comparo ambos
if (formatoIt.equals(formato)) {
// Formato esta en mi lista “negra” :)
return true;
}
}

// Si llego hasta aqui es porque el elemento no estaba
return false;
}

/** Metodo que genera una serie de valores por defecto para las opciones
* de renderizado de las imagenes. */
private void cargarOpcionesRenderizadoDefecto() {

/* Cargo las opciones de renderizado por defecto para la clase: en general,
en este caso se tiende a la calidad de las imagenes generadas */
opcionesRenderizadoImagenes = new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_DITHERING,
RenderingHints.VALUE_DITHER_ENABLE);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_DEFAULT);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_PURE);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
opcionesRenderizadoImagenes.put(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_QUALITY);
}

/** Devuelve la lista de formatos disponibles a leer por ImageIO
* @return un array de strings con los mismos.
*/
public String[] dameListadoFormatosUsables(){
return ImageIO.getReaderFormatNames();
}

/** Calcula el factor de escala minimo y en base a eso
* escala la imagen segun dicho factor.
* @param maximoAncho maximo tamaño para el ancho de la nueva imagen
* @param maximoAlto maximo tamaño para el alto de la nueva imagen
* @param formato Formato de la imagen. Determina los filtros a aplicar
* @param imagen Imagen original que vamos a escalar
* @return Devuelve la imagen escalada
* @throws IOException Excepciones de entrada / salida
*/
public BufferedImage escalarATamanyo(final File ficheroImagen,
final Integer maximoAncho, final Integer maximoAlto,
final String formato) throws IOException {

// Lo primero es obtener un BufferedImage
BufferedImage imagenFichero = ImageIO.read(ficheroImagen);

// Aplico otro metodo de la clase
return escalarATamanyo(imagenFichero, maximoAncho, maximoAlto, formato);
}

/** Calcula el factor de escala minimo y en base a eso
* escala la imagen segun dicho factor.
* @param maximoAncho maximo tamaño para el ancho de la nueva imagen
* @param maximoAlto maximo tamaño para el alto de la nueva imagen
* @param formato Formato de la imagen. Determina los filtros a aplicar
* @param imagen Imagen original que vamos a escalar
* @return Devuelve la imagen escalada
*/
public BufferedImage escalarATamanyo(final BufferedImage imagen,
final Integer maximoAncho, final Integer maximoAlto,
final String formato) {

// Comprobacion de parametros
if (imagen == null || maximoAlto <= 0 || maximoAncho <= 0) {
return imagen;
}

// Capturo ancho y alto de la imagen
int anchoImagen = imagen.getHeight();
int altoImagen = imagen.getWidth();

// Segunda comprobacion de parametros
if (anchoImagen == 0 || altoImagen == 0) {
return imagen;
}

// Calculo la relacion entre anchos y altos de la imagen
double escalaX = (double)maximoAncho / (double)anchoImagen;
double escalaY = (double)maximoAlto / (double)altoImagen;

// Tomo como referencia el minimo de las escalas
double fEscala = Math.min(escalaX, escalaY);

// Devuelvo el resultado de aplicar esa escala a la imagen
return escalar(fEscala, imagen, formato);
}

/** Escala una imagen en porcentaje.
* @param factorEscala ejemplo: factorEscala=0.6 (escala la imagen al 60%)
* @param srcImg una imagen en formato BufferedImage
* @param formatoOrigen Formato de la imagen. Determina los filtros a aplicar
* @return un BufferedImage escalado
*/
public BufferedImage escalar(final Double factorEscala,
final BufferedImage srcImg, final String formatoOrigen) {

// Comprobacion de parametros
if (srcImg == null || factorEscala == 0) {
return null;
}

// Preparo el tipo de los nuevos BufferedImage
int tipoFormatoBufferedReader;
if (formatoOrigen.equals(CodigosFormatosImagenes.CODIGO_FORMATO_GIF)) {
tipoFormatoBufferedReader = srcImg.getType();
} else {
tipoFormatoBufferedReader = BufferedImage.TYPE_INT_RGB;
}

// Caso de que realmente tengamos que escalar …
BufferedImage filtroInicial = null;

// Compruebo escala nula
if (factorEscala == 1) {

// En ese caso, devuelvo una copia de la imagen original
BufferedImage copia = new BufferedImage (srcImg.getWidth(),
srcImg.getHeight(), tipoFormatoBufferedReader);
copia.setData(srcImg.getData());
return copia;
} else {

// Se trata de una reduccion muy acuciada ?
if (factorEscala < UMBRAL_APLICACION_FILTRO_CONVOLUCION &&
esFormatoRequiereReduccionRuido(formatoOrigen)) {

/* Para las imagenes cuyo factor de escala sea menor que el 0.5 …
Preparo un objeto de tipo Kernel */
Kernel kernel = crearKernelEscala(factorEscala);

// Lanzo una transformacion afin previa de suavizado
ConvolveOp op = new ConvolveOp(
kernel, ConvolveOp.EDGE_NO_OP, null);

// Almaceno en filtroInicial la imagen suavizada
BufferedImage copia = new BufferedImage (srcImg.getWidth(),
srcImg.getHeight(), tipoFormatoBufferedReader);
copia.setData(srcImg.getData());

filtroInicial = op.filter(copia, filtroInicial);
}
else {
// Factores de escala sin suavizado
filtroInicial = srcImg;
}
}

// De aqui en adelante, debemos trabajar en base a filtroInicial

// La creo con las opciones de renderizado que tuviesemos
AffineTransformOp op = new AffineTransformOp(AffineTransform.getScaleInstance(factorEscala, factorEscala),
opcionesRenderizadoImagenes);
BufferedImage resultadoFiltro = op.filter(filtroInicial, null);

/* Balanceo entre elementos BufferedImage para eliminar canales
de transparencia extras, si hay */
BufferedImage biConversion = new BufferedImage (resultadoFiltro.getWidth(),
resultadoFiltro.getHeight(), tipoFormatoBufferedReader);
Graphics2D g = biConversion.createGraphics();

g.setRenderingHints(opcionesRenderizadoImagenes);
g.drawImage(resultadoFiltro, 0, 0, Color.WHITE , null);

// Devuelvo el resultado de aplicar el filtro sobre la imagen
return biConversion;
}

/** Metodo que guarda una imagen en disco
* @param imagen Imagen a almacenar en disco
* @param rutaFichero Ruta de la imagen donde vamos a salvar la imagen
* @param formato Formato de la imagen al almacenarla en disco
* @return Booleano indicando si se consiguio salvar con exito la imagen
* @throws IOException Excepciones de entrada / salida generales
*/
public Boolean salvarImagen(final BufferedImage imagen,
final String rutaFichero, final String formato)
throws IOException {

// Comprobacion de parametros
if (imagen != null && rutaFichero != null && formato != null) {
ImageIO.write( imagen, formato, new File( rutaFichero ));
return true;
} else {
// Fallo en tema de parametros
return false;
}
}

/** Metodo que crea un objeto de tipo Kernel para aplicar en
* posteriores transformaciones.
* @param factorEscala Factor de escala que tiene la imagen
* @return Objeto Kernel construido
*/
private Kernel crearKernelEscala(final Double factorEscala) {

// Calculos matematicos de proporciones de suavizado
int tamanyo = 1 + (int) (FACTOR_CONVOLUCION_SUAVIZADO / factorEscala);
float[] datos = new float[tamanyo * tamanyo];
float factor = 1 / (float) datos.length;
for (int i = 0; i < datos.length; i++) {
datos[i] = factor;
}

// Devuelvo un objeto Kernel entendible por el API
return new Kernel(tamanyo, tamanyo, datos);
}
}

CodigosFormatosImagenes: Por claridad, separé los códigos de los formatos de las imágenes en otra clase independiente.

/** Clase de constantes que nos ayuda a mantener algunos codigos de
* formatos graficos utiles para las comparaciones. */
public class CodigosFormatosImagenes {

/* Constantes */
/** Codigo para el formato GIF – Graphics Interchange Format . */
public static final String CODIGO_FORMATO_GIF = “gif”;

/** Codigo para el formato PNG – Portable Network Graphics . */
public static final String CODIGO_FORMATO_PNG = “png”;

/** Codigo para el formato BMP en minusculas – Mapa de Bits . */
public static final String CODIGO_FORMATO_BMP_MINUSC = “bmp”;

/** Codigo para el formato BMP en mayusculas – Mapa de Bits . */
public static final String CODIGO_FORMATO_BMP_MAYUSC = “BMP”;

/** Codigo para el formato JPG en minusculas -
* Joint Photographic Experts Group . */
public static final String CODIGO_FORMATO_JPG_MINUSC = “jpg”;

/** Codigo para el formato JPG en mayusculas -
* Joint Photographic Experts Group . */
public static final String CODIGO_FORMATO_JPG_MAYUSC = “JPG”;

/** Codigo para el formato JPEG en minusculas -
* Joint Photographic Experts Group . */
public static final String CODIGO_FORMATO_JPEG_MINUSC = “jpeg”;

/** Codigo para el formato JPEG en mayusculas -
* Joint Photographic Experts Group . */
public static final String CODIGO_FORMATO_JPEG_MAYUSC = “JPEG”;

/** Codigo para el formato WBMP en mayusculas -
* Wireless Application Protocol Bitmap Format . */
public static final String CODIGO_FORMATO_WBMP_MINUSC = “wbmp”;

/** Codigo para el formato WBMP en mayusculas -
* Wireless Application Protocol Bitmap Format. */
public static final String CODIGO_FORMATO_WBMP_MAYUSC = “WBMP”;
}