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”;
}

17 comentarios para “Un poco de thumbnails en Java”

  1. Iván Dice:

    Gracias Javier, está muy bueno tu código, hise pruebas y los resultados son notables. Felicitaciones.

    Saludos.

  2. Javier Murillo Dice:

    Hola Iván,

    Me alegro mucho que te fuese de utilidad.

    Cualquier futura sugerencia, mejora, … no dudes en comentarmela.

    Un saludo !!

  3. jorge Dice:

    gracias javier, eres un teso

  4. chechi526 Dice:

    Muy interesante tu ejemplo me salvaste una preguntita
    no sabes como trasladar una imagen de una carpeta a otra en java o trarladar la imagen de un cliente a un servidor
    muy bueno el tutorial

  5. Javier Murillo Dice:

    Hola Chechi,

    Para poder mover una imagen de una carpeta a otra dentro de un servidor puedes hacer uso del método renameTo de la clase File por ejemplo aunque te borrará la fuente o bien utilizar la clase File Channel del paquete java.nio. Al ser una operación muy común, te recomiendo que busques en Google ya que lo tendrás el propio código en mil sitios.

    Si lo que quieres es subir una imagen al servidor, puedes utilizar API´s como el que proporciona Jakarta del File Upload (http://commons.apache.org/fileupload/). Te recomiendo que la eches un vistazo ya que además trae documentación bastante clara sobre su uso.

    Un saludo y buena suerte !

  6. Thalia Dice:

    Mil mil gracias por haber compartido esta clase! Me has salvado de un quiebre de cabeza =)

  7. Juan Dice:

    Estimado Javier, he probado el proyecto y da buenisimos resultados. felicitaciones!.

    Ahora he probado con una imagen JPG de dimensiones 2518×2448 y no entra en el proceso de cambio de tamaño.
    Estoy intentando solucionarlo pero la verdad estoy en proceso de asimilar tu codigo y tal vez me podrias dar una pista del porque pasa eso y en que parte intervenir ( si es que ya no lo has solucionado).

    Muchas gracias nuevamente y espero tu respuesta.

    Saludos.

    • Javier Murillo Blanco Dice:

      Hola Juan,

      Me alegro de que el código te fuese de ayuda.

      Es posible que suba una nueva versión vía Maven o descarga directa vía JAR.

      En principio no debería tener problemas con imágenes grandes pero le echo un vistazo.

      Esta semana estoy en Andorra pero a ver si la próxima saco un rato.

      Un saludo y bienvenido al blog !

  8. Juan Dice:

    Javier, hise una prueba mas acuciosa y al parecer se cae en el metodo:

    public BufferedImage escalar(final Double factorEscala,
    final BufferedImage srcImg, final String formatoOrigen)

    en la linea donde está la instrucción:

    BufferedImage copia = new BufferedImage(srcImg.getWidth(),
    srcImg.getHeight(), tipoFormatoBufferedReader);

    ————————————-
    DATOS DE LA IMAGEN
    Width: 2518
    Height: 2448
    escalaX: 0.03177124702144559
    escalaY: 0.032679738562091505
    FEscala: 0.03177124702144559
    ————————————–

    Espero que esa información te sirva de ayuda.

    Saludos y suerte en el viaje.

  9. Juan Dice:

    Dato importante olvidado: Tipo imagen JPG.

    Saludos.

  10. Hernan Dice:

    Hola a todos, hise pruebas y es verdad, se cae con imagenes de ese tamaño, ahora no tengo idea del motivo. Que será?.

    pd: Felicidades Javier por el código.

  11. Juan Dice:

    Hola javier,

    mejor dicho, hola de nuevo xD, has descubierto el porque del error?,

    suerte
    bye.

  12. federico Dice:

    hola yo lo que quiero desarrollar es una imagen que me genere un clave aleatoria algo asi como esto https://hipservice.live.com/hipImageDirect.srf?id=68692&config=Hard8Char&tk=1238733145523 para dale mas seguridad y no se pueda copiar el codigo,si sabe una manera de acerlo dar una idea

  13. diego Dice:

    hola javier he tratado tu codigo y me gustaria implementarla en una aplicación de escritorio con la plataforma eclipse pero me di algunos problemas como puedo implementarlo, mi proyecto se basa en la realización de carnts y para ello me seria de mucha importancia la utilizacion de thubnails ya que me evitaria trabajar con algun editoir de imagenes para los carnts espero me puedas ayudar un saludo

  14. Javier Murillo Dice:

    A todos los que me habeis dado ideas de mejora, … por comentarios en post o mail, deciros que se acerca la siguiente version de la librería.

    Gracias !

  15. FouCrazy Dice:

    Buenos dias Javier, ¿te importaría ponerme los imports y la librerías que utiliza? es que mi proyecto ya tiene multitud de librerias y tengo varias opciones para los imports y no consigo dar con la combinación válida.

    Un saludo y porsupuesto, muchas gracias.


Escribe un comentario