📘 Backend/Concurrency

Image Processing - Latency Optimization (지연시간 최적화)

신건우 2023. 12. 3. 00:03

📘 Image Processing

어떤 사진에 1가지 종류의 꽃이 있고, 동일한 종의 꽃이지만 색깔은 2가지(흰색, 보라색)로 나뉘는 사진이 있습니다.

이 사진은 3036 x 4048이며 대략 1200만 픽셀로 이루어져 있고, 각 픽셀의 색깔은 ARGB로 불리는 4가지 Byte로 표현됩니다.

  • A (Alpha) : 픽셀의 투명도 값을 나타냄
  • R (Red) : 빨간색
  • G (Green) : 초록색
  • B (Blue) : 파란색

RGB의 조합으로 모든 색을 표현할 수 있습니다.


모든 꽃의 색깔을 보라색으로 만들기 위해 보라색으로 칠할 곳을 식별합니다.

  • 색상 Re-Coloring 알고리즘을 Sequantial 버전으로 구현
  • 구현을 모듈화해서 멀티스레드 작업으로 나누기

img


📘 Single Threading

ImageProcessing 클래스를 만들어 2가지 색깔을 가지는 1종류의 꽃을 하나의 색으로 통일시켜 보겠습니다.

클래스에서 사용한 함수의 설명부터 하고 코드를 적겠습니다.


📕 getRed(), getGreen(), getBlue()

getBlue()

  • Alpha, Red, Green을 마스킹하며, 픽셀마다 비트마스크를 적용해서 컴포넌트를 전부 0으로 설정하고 0x000000FF를 적용합니다.

getGreen()

  • Alpha, Red, Blue를 마스킹하며, Green 컴포넌트가 오른쪽에서 2번째 바이트니까 0x0000FF00를 적용하고 왼쪽으로 8비트 움직입니다.

getRed() 함수도 동일하게 만들어 줍니다.


이해가 어렵다면 아래 테이블을 봐주세요.

A 25~32 R 17~24 G 9~16 B 1~8
0000 0000 0000 0000 0000 0000 0000 0000

만약 getRed()라고 가정하면 R 부분을 제외한 나머지 비트들을 전부 & 연산 후, 16칸 >> 쉬프트 연산을 통해 R 값을 뽑아내는 겁니다.


📕 createRGBFromColors()

  • RGB 개별 값을 합쳐서 픽셀에 RGB 값을 넣어주는 함수입니다.
  • 각 비트 자리에 OR 연산을 통해 파라미터로 들어온 rgb 값을 대입한 후 리턴합니다.

📕 isShadeOfGray()

  • 파라미터로 들어온 RGB 색상 값이 회색 음영인지 여부를 판단하는 함수입니다.
  • RGB 색상 값의 빨강, 초록, 파랑 성분 간의 차이를 계산하고, 이 차이가 일정 범위 내에 있는 경우에만 해당 색상을 회색으로 간주합니다.
  • 각 성분 간 차이 값을 임의로 30으로 설정 하였습니다.
  • 만약 조건문이 전부 만족된다면, 해당 RGB 색상은 회색 음영으로 간주하고 true를 반환합니다.
  • 간단히 말하면 RGB 각 색상이 비슷하면 회색으로 간주하는 로직입니다.

📕 recolorPixel()

  • 원본 이미지의 RGB를 추출해 새로운 이미지에 색상을 입힐 함수입니다.
  • 기존 이미지의 RGB값을 가지고 회색 계열인지 검증 후, 회색이라면 특정 색상의 값들을 올리거나 내립니다..
  • 이 때, 바이트에 저장될 수 있는 최대값(255)를 넘지 않도록 Math.min()을 이용해 255로 제한해 줍니다.
  • 그리고 "-"를 할때는 음수가 되지 않도록 Math.max()를 이용해 최대값을 0으로 잡아줍니다.
  • 조건문의 else 부분은 만약 회색계열이 아니라면 오래된 픽셀을 제거하고 색상 값을 다시 할당합니다.

😯 각 값들의 + - 수치를 정한 기준 😯

  • 보라색은 Red, Blue의 조합입니다.
  • 그럼 Green은 확 낯추고 보라색은 Red에 더 가깝기 때문에 Red값을 올리고 Blue를 낯추었습니다.

📕 setRGB()

  • recolorPixel() 함수로 새로운 이미지에 색상을 재설정하고 타깃(BufferedImage 객체, 픽셀 좌표, RGB 값)에 RGB를 할당합니다.

📕 recolorImage()

  • 원본과 결과 이미지, 좌측과 상단의 코너 값, 이미지 너비와 높이를 파라미터로 받습니다.
  • 첫번째 For Loop는 좌측 코너와 행의 끝 사이의 모든 X 값을 Loop 합니다.
  • 두번쨰 For Loop는 Loop를 도는 모든 x값의 상단/하단의 모든 y값을 반복합니다.
  • 그리고 모든 x,y 값에 recolorPixel() 함수를 호출합니다.

📕 recolorSingleThread()

  • 전체 이미지에 대해 recolorImage()를 호출해 이미지를 단일 스레드로 색칠합니다.

📕 main()

  • 메인 함수에서는 이미지 파일 처리를 위해 BufferedImage 클래스를 사용합니다.
  • BufferedImage 클래스는 픽셀, 컬러스페이스, 디멘션 등 이미지 데이터 표현 & 픽셀 조작을 위한 다양한 함수를 제공합니다.
public class ImageProcessing {  
    public static final String SOURCE_IMG = "img/Flowers.png";  
    public static final String DESTINATION_IMG = "Thread/src/main/resources/img/Purple-Flowers.png";  

    public static void main(String[] args) throws IOException {  
        Resource originalResource = new ClassPathResource(SOURCE_IMG);  
        BufferedImage originalImage = ImageIO.read((originalResource.getFile()));  
        BufferedImage resultImage = new BufferedImage(  
                originalImage.getWidth(),  
                originalImage.getHeight(),  
                BufferedImage.TYPE_INT_RGB // Color Space  
        );  

        recolorSingleThread(originalImage, resultImage);  

        File outputResult = new File(DESTINATION_IMG); // 파일 생성  
        outputResult.getParentFile().mkdirs();  
        ImageIO.write(resultImage, "png", outputResult); // 파일 Write    
      }  

    /* 원본, 결과 이미지 픽셀의 x,y 좌표를 표현하는 BufferedImage 객체를 가짐 */    
    public static void recolorPixel(BufferedImage original, BufferedImage result, int x, int y) {  
        int originalRGB = original.getRGB(x, y);  

        int red = getRed(originalRGB);  
        int green = getGreen(originalRGB);  
        int blue = getBlue(originalRGB);  

        int newRed, newGreen, newBlue;  
        /**  
         * 원본 이미지의 RGB가 회색 계열이라면 색상들의 값을 올리거나 내려줌  
         * 이때, 음수가 되지 않도록 Math.max(), 255를 넘어가지 않도록 Math.min()을 이용해줌  
         */  
        if (isShadeOfGray(red, green, blue)) {  
            newRed = Math.min(255, red + 10);  
            newGreen = Math.max(0, green - 80);  
            newBlue = Math.max(0, blue - 20);  
        } else {  
            newRed = red;  
            newGreen = green;  
            newBlue = blue;  
        }  

        int resultRGB = createRGBFromColors(newRed, newGreen, newBlue);  
        setRGB(result, x, y, resultRGB);  
    }  

    /* BufferedImage에 rgb를 설정 */    
    public static void setRGB(BufferedImage image, int x, int y, int rgb) {  
        image.getRaster().setDataElements(x, y, image.getColorModel().getDataElements(rgb, null));  
    }  

    /* 지정된 영역 안의 모든 픽셀의 색상을 재설정 */    
    public static void recolorImage(BufferedImage original,  
                               BufferedImage result,  
                               int leftCorner,  
                               int topCorner,  
                               int width,  
                               int height) {  
        for (int x = leftCorner; x < leftCorner + width && x < original.getWidth(); x++) {  
            for (int y = topCorner; y < topCorner + height && y < original.getHeight(); y++) {  
                recolorPixel(original, result, x, y);  
            }  
        }  
    }  

    /* 전체 이미지에 대해 recolorImage()를 호출해 이미지를 단일 스레드로 색칠 */    
    public static void recolorSingleThread(BufferedImage original, BufferedImage result) {  
        recolorImage(original, result, 0, 0, original.getWidth(), original.getHeight());  
    }  

    /* ========== Utils ========== */  

    /* 픽셀의 특정 색상 값을 취하고 픽셀에 넣을 회색을 결정 */    
    /* 빨강, 초록, 파랑 성분 간의 차이를 절대값으로 계산하고, 각 차이가 30 이하인지 확인 */    
    public static boolean isShadeOfGray(int red, int green, int blue) {  
        return Math.abs(red - green) < 30 && Math.abs(red - blue) < 30 && Math.abs(green - blue) < 30;  
    }  

    /* RGB 개별 값을 합쳐서 픽셀에 RGB 값을 넣어주는 함수 */    
    public static int createRGBFromColors(int red, int green, int blue) {  
        int rgb = 0;  

        rgb |= 0xFF000000; // A  
        rgb |= red << 16; // R  
        rgb |= green << 8; // G  
        rgb |= blue; // B  

        return rgb;  
    }  

    /* 특정 색상을 추출하는 함수 */    
    public static int getRed(int rgb) {  
        return (rgb & 0x00FF0000) >> 16;  
    }  

    public static int getGreen(int rgb) {  
        return (rgb & 0x0000FF00) >> 8;  
    }  

    public static int getBlue(int rgb) {  
        return rgb & 0x000000FF;  
    }  
}

img

위 이미지 프로세싱 코드는 싱글 스레드 기반으로 진행하였습니다.

이제 멀티스레드 솔루션을 생각해서 멀티 스레딩으로 작업을 어떻게 분할할지 생각해보죠.


📘 Multi-Threading

위 코드에서 이미지를 분할하여 가진 스레드 수 만큼 이미지를 병렬 처리 해보겠습니다.

즉 스레드 개수에 따라 이미지를 나누고 모든 스레드의 작업이 끝나면 BufferedImage를 파일에 저장합니다.


📕 ImageProcessing

위 코드에서 함수 하나만 추가합니다.

스레드의 수에 따라 모든 스레드의 width는 원본 이미지의 width로 설정합니다.

반면 높이는 스레드의 개수에 따라 나눠야 하므로 원본 이미지의 Height() / 스레드 수로 나눠줍니다.


그리고, 모든 스레드는 싱글 스레드로 처리하는 것처럼 LeftCorner부터 처리합니다.

하지만 높이를 기준으로 이미지를 잘랐듯이 스레드의 TopCorner 부분도 다릅니다.

TopCorner는 스레드에 맞게 변하는 threadMultiplier 변수 * 각 스레드의 높이 입니다.


그리고, recolorImage() 함수를 다시 이용해서 색을 입혀주고 스레드 리스트에 스레드를 추가합니다.

그 후, 모든 스레드를 시작하고 모두 join() 해주면 끝입니다.

public static void recolorMultiThread(BufferedImage original, BufferedImage result, int numberOfThreads) {  
    List<Thread> threadList = new ArrayList<>();  
    int width = original.getWidth();  
    int height = original.getHeight() / numberOfThreads;  

    for (int i = 0; i < numberOfThreads; i++) {  
        final int threadMultiplier = i;  

        Thread thread = new Thread(() -> {  
            int leftCorner = 0;  
            int topCorner = height * threadMultiplier;  

            recolorImage(original, result, leftCorner, topCorner, width, height);  
        });  

        threadList.add(thread);  
    }  

    for (Thread thread : threadList) {  
        thread.start();  
    }  

    for (Thread thread : threadList) {  
        try {  
            thread.join();  
        } catch (InterruptedException e) {  
            log.error("Thread Interrupted");  
        }  
    }  
}

이제 싱글스레드, 멀티스레드 두 작업 시간을 비교해보겠습니다.


📘 작업 시간 비교

이미지 처리 작업의 시작 시간과 종료 시간을 캡쳐합니다.


📕 싱글 스레드

public static void main(String[] args) throws IOException {  
    Resource originalResource = new ClassPathResource(SOURCE_IMG);  
    BufferedImage originalImage = ImageIO.read((originalResource.getFile()));  
    BufferedImage resultImage = new BufferedImage(  
            originalImage.getWidth(),  
            originalImage.getHeight(),  
            BufferedImage.TYPE_INT_RGB // Color Space  
    );  

    long startTime = System.currentTimeMillis();  

    recolorSingleThread(originalImage, resultImage);  

    long endTime = System.currentTimeMillis();  

    long duration = endTime - startTime;  

    File outputResult = new File(DESTINATION_IMG); // 파일 생성  
    outputResult.getParentFile().mkdirs();  
    ImageIO.write(resultImage, "png", outputResult); // 파일 Write  
    log.info("총 작업에 걸린 시간 : {}", duration);  
}

걸린 시간

> Task :ImageProcessing.main()
23:40:20.221 [main] INFO com.thread.image.ImageProcessing -- 총 작업에 걸린 시간 : 610

📕 멀티 스레드

스레드를 2개, 3개 두 번 돌려봤습니다. 스레드를 1개씩 추가할 떄마다 걸린 시간이 줄어듭니다.

public static void main(String[] args) throws IOException {  
    Resource originalResource = new ClassPathResource(SOURCE_IMG);  
    BufferedImage originalImage = ImageIO.read((originalResource.getFile()));  
    BufferedImage resultImage = new BufferedImage(  
            originalImage.getWidth(),  
            originalImage.getHeight(),  
            BufferedImage.TYPE_INT_RGB // Color Space  
    );  

    long startTime = System.currentTimeMillis();  

    recolorMultiThread(originalImage, resultImage, 3);  

    long endTime = System.currentTimeMillis();  

    long duration = endTime - startTime;  

    File outputResult = new File(DESTINATION_IMG); // 파일 생성  
    outputResult.getParentFile().mkdirs();  
    ImageIO.write(resultImage, "png", outputResult); // 파일 Write  
    log.info("총 작업에 걸린 시간 : {}", duration);  
}

걸린 시간

> Task :ImageProcessing.main()
23:42:31.405 [main] INFO com.thread.image.ImageProcessing -- 총 작업에 걸린 시간 : 429

> Task :ImageProcessing.main()
23:42:50.606 [main] INFO com.thread.image.ImageProcessing -- 총 작업에 걸린 시간 : 333

📕 성능

결론적으로만 보면 성능이 크게 향상되고 지연 시간이 줄어들었습니다.

이 후에도 물리적인 쿼드코어 개수에 맞게 스레드를 변경하면서 많이 실행해 봤는데,

물리적은 코어 수에 가까워질수록 지연시간이 줄어들었습니다.


하지만, 하이퍼스레딩 된 가상 코어 개수에 맞게 스레드수를 늘리기 시작하니 성능 향상이 줄고 중간쯤 부터는 오히려 역효과가 났습니다.

왜냐하면 하이퍼스레딩 된 가상코어는 원래의 물리코어의 리소스를 공유하기 때문입니다.

스레드의 수를 가상코어보다도 더 늘리면 성능적으로 어떠한 이점도 없음도 확인 하였습니다.


📕 원본 이미지의 크기 변동

원본 이미지의 해상도를 낯추고 스레드를 동일하게 늘리면 어떨까요?

기존 해상도로 멀티스레딩을 돌린결과 성능이 70%정도 좋아졌었습니다.

하지만 해상도가 낮아지니 (처리할 수 있는 픽셀의 수가 떨어짐) 멀티스레딩 처리를 함으로 인한 이점이 더 적어졌습니다.


759x1012 픽셀보다 더 작은 해상도의 이미지에서는 오히려 멀티스레딩이 Sequantial 방식보다 더 느려집니다.

느려지는 이유는 컨텍스트 스위칭이나 오버헤드가 늘어났지만 처리해야 할 원본 이미지의 픽셀수는 낮아졌기 떄문입니다.


📕 결론

  • 큰 작업을 멀티스레드로 실행하면 속도와 성능을 크게 개선할 수 있다.
  • 스레드가 코어보다 많이 생성되면 Blocking 호출이 없고 단순히 계산만 하는 문제에 대해선 역효과를 낳는다.
  • 멀티스레드 구현에는 그만한 비용이 드므로 분할 효과를 보려면 작업이 커야 한다. (오버헤드, 스위칭 비용)