html을 파싱해서 썸네일 이미지를 추출해 Amazon S3에 저장해야 하는 일이 있었습니다.
사이드 프로젝트 인 만큼 이미지 용량을 줄일 수 있으면 S3 비용도 아끼고 프론트 렌더링 속도 역시 개선되겠다고 생각이 들었고 resize와 webp 이미지 포맷을 사용하기로 결정했습니다.
이미지 읽기
URL 정보를 통해서 이미지를 읽어와야 했었는데 Java에서 javax.imageio.ImageIO 클래스를 사용하면 쉽게 이미지를 읽고 쓸 수 있었습니다.
URL 혹은 파일을 읽어서 처리가 가능합니다.
BufferedImage image = ImageIO.read(new URL(이미지 주소));
BufferedImage image = ImageIO.read(new File(이미지 저장 위치));
- java.awt.image.BufferedImage를 통해서 이미지의 width, height, RGB등 메타 데이터를 얻을 수 있습니다.
scrimage 라이브러리(자바, 코틀린, 스칼라)를 통해서도 이미지를 읽어올 수 있습니다.
ImmutableImage image = ImmutableImage.loader().fromFile(file);
메타데이터도 쉽게 얻어올 수 있습니다.
ImageMetadata meta = ImageMetadata.fromStream(stream);
Arrays.stream(meta.tags()).forEach(tag -> System.out.println(tag));
출력
...
Tag{name='Compression Type', type=-3, rawValue='0', value='Baseline'}
Tag{name='Data Precision', type=0, rawValue='8', value='8 bits'}
Tag{name='Image Height', type=1, rawValue='405', value='405 pixels'}
Tag{name='Image Width', type=3, rawValue='594', value='594 pixels'}
Tag{name='Resolution Units', type=7, rawValue='1', value='inch'}
Tag{name='X Resolution', type=8, rawValue='300', value='300 dots'}
Tag{name='Y Resolution', type=10, rawValue='300', value='300 dots'}
Tag{name='Thumbnail Width Pixels', type=12, rawValue='0', value='0'}
Tag{name='Thumbnail Height Pixels', type=13, rawValue='0', value='0'}
Tag{name='Image Width', type=256, rawValue='4928', value='4928 pixels'}
Tag{name='Image Height', type=257, rawValue='3280', value='3280 pixels'}
Tag{name='Bits Per Sample', type=258, rawValue='8 8 8', value='8 8 8 bits/component/pixel'}
Tag{name='Photometric Interpretation', type=262, rawValue='2', value='RGB'}
Tag{name='Image Description', type=270, rawValue='during the Sky Bet Championship match between Middlesbrough and Wolverhampton Wanderers at Riverside Stadium on April 14, 2015 in Middlesbrough, England.', value='during the Sky Bet Championship match between Middlesbrough and Wolverhampton Wanderers at Riverside Stadium on April 14, 2015 in Middlesbrough, England.'}
Tag{name='Make', type=271, rawValue='NIKON CORPORATION', value='NIKON CORPORATION'}
Tag{name='Model', type=272, rawValue='NIKON D4S', value='NIKON D4S'}
Tag{name='Orientation', type=274, rawValue='1', value='Top, left side (Horizontal / normal)'}
Tag{name='Samples Per Pixel', type=277, rawValue='3', value='3 samples/pixel'}
Tag{name='X Resolution', type=282, rawValue='72', value='72 dots per inch'}
Tag{name='Y Resolution', type=283, rawValue='72', value='72 dots per inch'}
...
이미지 사이즈 조정하기
BufferedImage (PNG)
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
public class ReadWriteImage {
public static void main(String[] args) {
try {
URL url = new URL(이미지 URL 주소);
// read an image from url
BufferedImage image = ImageIO.read(url);
// resize image to 300x150
Image scaledImage = image.getScaledInstance(300, 150, Image.SCALE_DEFAULT);
// save the resize image aka thumbnail
ImageIO.write(
convertToBufferedImage(scaledImage),
"png",
new File(파일 저장 주소));
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("Done");
}
// convert Image to BufferedImage
public static BufferedImage convertToBufferedImage(Image img) {
if (img instanceof BufferedImage) {
return (BufferedImage) img;
}
// Create a buffered image with transparency
BufferedImage bi = new BufferedImage(
img.getWidth(null), img.getHeight(null),
BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics2D = bi.createGraphics();
graphics2D.drawImage(img, 0, 0, null);
graphics2D.dispose();
return bi;
}
}
JPEG, JPG를 위에 코드로 이미지 파일을 생성하려고 예외는 발생하지 않는데 파일이 생성되지 않는 문제가 있었습니다.
- PNG는 알파 채널을 지원하지만 JPEG 파일은 알파 채널을 지원하지 않는 것을 인지하지 못했습니다. (투명도)
- 알파채널은 RGB 3개의 채널 외에 편집용 정보를 취급하는 보조채널을 말합니다.
BufferedImage.TYPE_INT_ARGB // 알파 채널 O
BufferedImage.TYPE_3BYTE_BGR // 알파 채널 X
- TYPE_3BYTE_BGR로 변경해주시면 정상적으로 JEPG, JPG 이미지 파일이 생성하는 것을 볼 수 있습니다.
scrimage 라이브러리
- resize 메서드도 존재하지만 이미지를 확장, 축소하시는 용도로 사용하시는 분들은 scale 메서드를 사용하시면 됩니다.
image.scaleToWidth(400) // keeps aspect ratio
image.scaleToHeight(200) // keeps aspect ratio
image.scaleTo(400, 400)
WebP
WebP 파일을 저장할 때 압축 방식(무손실 압축, 손실 압축)을 선택할 수 있으므로 데이터 손실 없이 또는 중요한 정보를 손실하지 않고 이미지를 압축할 수 있습니다. (참조)
- WebP 무손실 이미지는 PNG에 비해 크기가 26% 더 작으며, JPEG 이미지보다 25~34% 더 작습니다.
- 손실 RGB 압축이 허용되는 경우 손실이 있는 WebP는 투명도도 지원하여 일반적으로 PNG에 비해 3배 작은 파일 크기를 제공합니다.
scrimage 라이브러리를 통해서 WebP로 변환하기
// 버전 정보: https://sksamuel.github.io/scrimage/changelog/
implementation("com.sksamuel.scrimage:scrimage-core:4.1.3")
implementation("com.sksamuel.scrimage:scrimage-webp:4.1.3") // WebP를 위해 추가
// 읽기
ImmutableImage.loader().fromFile(new File("someimage.webp"))
// 쓰기
myimage.output(WebpWriter.MAX_LOSSLESS_COMPRESSION,"output.webp");
Gif도 읽고 쓸 수 있다.
// 읽기
AnimatedGifReader.read(ImageSource.of(File("animated.gif"));
// 쓰기
animatedGif.bytes(Gif2WebpWriter.DEFAULT);
animatedGif.output(Gif2WebpWriter.DEFAULT, "output.webp");
적용 코드 (코틀린)
URL 형식으로 데이터를 읽어와야 했었기 때문에 ImageIO를 통해서 BufferedImage를 가져온 다음에 비율에 적절한 썸네일을 추출했습니다. (16:9 ~ 1:1 == 56.25 ~ 100%)
val images = bufferImages.filter(::imageRule).take(2)
private fun imageRule(it: ThumbnailImg) =
it.hasMinimumImageSize(300) && it.hasAspectRatioRange(IMAGE_ASPECT_RATIO_RANGE)
비율 범위에 해당하는 이미지에서 width를 기준으로 사이즈를 조정하고 .webp로 변환했습니다.
fun imageResizeAndConvertWebp(image: BufferedImage): String {
val immutableImage = getImmutableImage(image)
.scaleToWidth(TARGET_WIDTH)
.output(WebpWriter.DEFAULT, File("$path/${UUID.randomUUID()}$WEBP_SUFFIX"))
}
private fun getImmutableImage(image: BufferedImage): ImmutableImage =
ImmutableImage.create(
image.width,
image.height,
PixelFactory.getPixelArrayFromImage(image),
BufferedImage.TYPE_3BYTE_BGR
)
ImmutableImage.create 메서드에서 Pixel[] 받게 되는데 stackoverflow(아래 코드)에서 작성한걸 이용해서 작성했습니다.
private static int[][] convertTo2DWithoutUsingGetRGB(BufferedImage image) {
final byte[] pixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData();
final int width = image.getWidth();
final int height = image.getHeight();
final boolean hasAlphaChannel = image.getAlphaRaster() != null;
int[][] result = new int[height][width];
if (hasAlphaChannel) {
final int pixelLength = 4;
for (int pixel = 0, row = 0, col = 0; pixel + 3 < pixels.length; pixel += pixelLength) {
int argb = 0;
argb += (((int) pixels[pixel] & 0xff) << 24); // alpha
argb += ((int) pixels[pixel + 1] & 0xff); // blue
argb += (((int) pixels[pixel + 2] & 0xff) << 8); // green
argb += (((int) pixels[pixel + 3] & 0xff) << 16); // red
result[row][col] = argb;
col++;
if (col == width) {
col = 0;
row++;
}
}
} else {
final int pixelLength = 3;
for (int pixel = 0, row = 0, col = 0; pixel + 2 < pixels.length; pixel += pixelLength) {
int argb = 0;
argb += -16777216; // 255 alpha
argb += ((int) pixels[pixel] & 0xff); // blue
argb += (((int) pixels[pixel + 1] & 0xff) << 8); // green
argb += (((int) pixels[pixel + 2] & 0xff) << 16); // red
result[row][col] = argb;
col++;
if (col == width) {
col = 0;
row++;
}
}
}
return result;
}
- 기존의 중첩 반복문 보다 10배 이상 빠른 코드
결과
JPEG
84.36kB -> 39.26kB(Resize Image) -> 23.02kB (Convert WebP) 72.71% 개선
PNG (손실 압축)
920.3 kB -> 366.94kB(Resize Image) -> 22.4kB(Convert WebP) 97.6% 개선
📚 reference
'개발' 카테고리의 다른 글
Spring에서 @Async 사용하기 (0) | 2024.06.17 |
---|---|
도메인 주도 설계의 사실과 오해 (0) | 2024.04.29 |
Getter 없이 Test해보기 (1) | 2023.11.24 |
EnumMap 적용하기! (1) | 2023.11.08 |
매직넘버, 리터럴 어디까지 상수 처리해야 돼? (4) | 2023.11.06 |