Open Source 개발

[오픈소스 개발기] pypipo는 어떻게 만들었을까?

콜레오네 2023. 8. 6. 23:49

 

안녕하세요.

 

오픈소스 pypipo 메인테이너 입니다.

 

pypipo 란?

어떤 이미지를 자동으로 피포페인팅 캔버스로 변환해주는 영상처리 기술 기반의 라이브러리 

 

Github

https://github.com/AutoPipo/pypipo

 

GitHub - AutoPipo/pypipo: Python Library based on EasyPipo

Python Library based on EasyPipo. Contribute to AutoPipo/pypipo development by creating an account on GitHub.

github.com

 

지금부터 어떻게 이미지가 피포페인팅 캔버스로 변환되는지 그 과정에 대해 서술하려 합니다.

모든 image processing 알고리즘은 Github 내 libs/process.py 에 있습니다.

 

 

1. 이미지 그림화 (Painting)

첫번째로 실제 이미지를 그림처럼 변경해주는 과정입니다.

결국 물감으로 캔버스에 칠했을때, 실제 그림으로 완성되어야 하니

결과물이 되는 결과이미지를 만들어두는 과정입니다.

Painting 적용 전후

위 그림에서 좌측이 원본, 우측이 그림처럼 변경된 결과 이미지 입니다.

 

여기서는

1. 이미지 흐림처리 Blurring

2. 이미지 내 색상 줄이기 K Means

두 가지 영상처리 기술을 활용했습니다.

 

Blurring

Bilateral Filter 메서드를 활용했습니다.

Bilateral 흐름 효과는 cv2 메서드 중에서 가장자리를 가장 잘 살려주는 흐름효과 입니다.

오픈소스의 목적이 그림을 그리기 위한 캔버스 제작이니, Bilateral 필터를 적용시킵니다.

 

Reducing Color

여기서는 K Means 알고리즘을 통해 이미지 내 수만가지 색상을 K개로 줄이는 과정을 거쳤습니다.
그리고 실제 모든 픽셀의 이미지에서 K개의 색상중 색상 거리(color distance, 유클리드 거리)가 가장 가까운 색을 선택하여 변경해줍니다. 

 

소스코드

# 이것은 Painting process의 일부 핵심 소스코드 입니다.

     def __blurring(self, div, sigma):
        """Image blurring

        Parameters
        ----------
        div : int
            Reducing numbers of color on image
        sigma : int
            bilateralFilter Parameter

        Returns
        -------
        blurred_image : np.ndarray
            blurred Image
        """

        BILATERAL_FILTER_RADIUS = -1  # Auto decision by sigmaSpace
        BILATERAL_FILTER_SIGMACOLOR_MIN = 10
        BILATERAL_FILTER_SIGMACOLOR_MAX = 120
        
        qimg = self.original_img.copy()  # copy original image
        
        sigma = max(sigma, BILATERAL_FILTER_SIGMACOLOR_MIN)
        sigma = min(sigma, BILATERAL_FILTER_SIGMACOLOR_MAX)
        
        # bilateral blurring
        blurred_image = cv2.bilateralFilter(qimg, BILATERAL_FILTER_RADIUS, sigma, sigma)
        
        # reduce numbers of color
        blurred_image = blurred_image // div * div + div // 2
        return blurred_image
    
    def __cluster_color_with_kmeans(self, image, number_of_color, attempts):
        """Cluster image color with k-means algorithm.

        Parameters
        ----------
        image : np.ndarray
            Input image
        number_of_color : int
            Number of color clustered
        attempts : int
            How many iterate try to k-means clustering

        Returns
        ----------
        color_clustered_image : np.ndarray
            Color clustered image
        color_index_map : np.ndarray
            a Array that contains clustered color indexs.
        """
        height, width = image.shape[:2]
        
        # transform color data to use k-means algorithm
        # need to trnasform into two-dimensional array
        # [[B, G, R], [B, G, R] ... , [B, G, R]]
        training_data_samples = image.reshape((height * width, 3)).astype(np.float32)

        # sse : Sum of squared error
        # labels : Array about label, show like 0, 1
        # centers : Cluster centroid array
        sse, labels, centers = cv2.kmeans(
                                        training_data_samples,  # Align training data, data type = np.float32
                                        number_of_color,        # number of cluster
                                        None,                   # Sort the cluster numbers for each sample
                                        
                                        # TERM_CRITERIA_EPS : End iteration when a certain accuracy is reached
                                        # TERM_CRITERIA_MAX_ITER : End iteration after a certain number of iterations
                                        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 
                                                    100000, # max_iter : Max number of iterations
                                                    0.0001), # epsilon : Specific Accuracy Required
                                        attempts = attempts,  # Number of iterations to run using different initial centroids
                                        
                                        # flags : To set the Initial Centroids
                                        # cv2.KMEANS_RANDOM_CENTERS : Random
                                        # cv2.KMEANS_PP_CENTERS : K-Means++ Algorithm
                                        # cv2.KMEANS_USE_INITIAL_LABELS : User selection
                                        flags = cv2.KMEANS_PP_CENTERS)
        
        # a Array that contains clustered color indexs.
        # it has same shape as Oringinal image, but the value it contains is a single-dimension.
        # it will be used to draw line along the colors.
        color_index_map = labels.reshape((height, width))

        centers = np.uint8(centers)
        res = centers[labels.flatten()]
        self.clustered_colors = centers

        # for returns
        sse = round(sse ** 0.5 // 10, 2)
        color_clustered_image = res.reshape((image.shape))
        return color_clustered_image, color_index_map

 

 

2. 선 그리기 (Drawing)

이 과정은 앞서 1번에서 설명한 그림처럼 변경된 이미지를 기반으로

모든 픽셀에 대해 색상값을 기준으로 색상이 변경되는 부분에 점을 찍어주어 경계선을 그려주는 과정입니다.

 

1번에서 우리는 (픽셀 xy 좌표, 색상값) 정보를 얻을 수 있습니다.

여기서 모든 색상값을 기준으로 같지않으면 검은색으로 채워주는 아주 간단한 과정입니다.

 

그 결과는 아래와 같습니다.

굉장히 지저분한 부분이 많습니다. 우리는 다음 과정에서 지저분한 라인을 제거해줄 것입니다.

 

 

소스코드

    def __draw_line(self):
        """Draw line with color boundary from painting image

        Parameters
        ----------
        painting : np.ndarray
            Input painting image

        Returns
        ----------
        web : np.ndarray
            Gray scale image that line drawn
        """

        # Find color index difference by comparing row and columns.
        hor_diff = self.__get_diff(self.color_index_map)
        ver_diff = self.__get_diff(self.color_index_map.T)

        # rotate 90 degree to fit shape with hor_diff
        ver_diff = ver_diff.T

        # merge horizontal and vertical difference by element-wise operation
        diff = ver_diff + hor_diff
        
        # set pixel color to black if there is a difference
        web = np.where(diff != 0, self.BLACK_COLOR, self.WHITE_COLOR)
        return web
    

    def __get_diff(self, map):
        """Draw outline on image
        Parameters
        ----------
        map : np.ndarray
            a array that contains simplified color index
        
        Returns
        ---------
        diff : np.ndarray
            a array that contains each row value diffences.
        """

        diff = np.zeros(map.shape) + self.WHITE_COLOR

        # subtracts current row and next row
        for y, row in enumerate(map[:-1]):
            next_row = map[y + 1]
            diff[y] = row - next_row

        return diff

 

 

3. 색상 인덱스 붙이기 (Numbering)

우리는 위 과정에서 색상을 k개로 구분하고, 색상 구분선을 그려주었습니다.

이때, 하나의 연결된 선들의 집합을 contours라고 부릅니다.

그럼 contours 내부에는 각각 하나의 색상만이 존재하게 됩니다.

 

실제로 피포페인팅에서 물감을 색칠하기 위해서는 숫자를 보고 매칭해야 합니다.

그래서 여기서는

각각 contours 내부에 알맞는 색상 번호(Index)를 적어주는 작업을 합니다.

 

여기서 가장 중요한 것은

번호를 contour 어느 위치에 찍어줘야할까? 라는 질문에 대한 대답입니다.

 

먼저 우리는 내접원을 구상했습니다.

하지만 내접원도 모든 경우를 해결해줄 수 없었습니다.

 

문제 상황 예시

 

왼쪽 그림은에서 내접원은 굉장히 다양하게 존재합니다.

그중 빨간원은 내접원이지만 최대반지름이 아닙니다.

최대 반지름인 파란 원을 찾아야 합니다.

 

오른쪽 그림은 빨간원이 가장 큰 반지름을 가진 내접원입니다.

하지만 contour가 겹쳐있어 내접원의 중심에 번호를 기입하게될 경우, 다른 contour에 기입되게 됩니다.

우리는 자식 contour를 제외한 면적중에서 내접원을 찾아야하고, 그게 파란 원입니다.

 

위와 같은 이슈를 해결하기 위해
각 contour를 별도로 계산하기로 합니다.
이미지서 자식 contour를 다른 색상으로 칠해주고, 남은 면적중에서의 내접원의 중심을 구해줍니다.

 

cv2.distanceTransform() 메서드를 이용하기로 합니다.

OpenCV distanceTransform() 함수는 이미지에서 0이 아닌 각 픽셀과 가장 가까운 0 픽셀 사이의 유클리드 거리를 계산하는 이미지 처리 함수 라고 합니다.

 

위 함수를 통해 

우리는 원하는 기능을 성공적으로 구현할 수 있었습니다.

최종 결과 이미지

물론 위와 같은 최종 결과 이미지는

기존 생성한 결과 이미지를 투명하게하여 뒤에 입히고

좌측 상단에 color label을 추가해주었습니다.

 

소스코드

    def __get_contours_information_from_web(self, web_image):
        """Get contours, hierarchy, image_bin from web image

        Parameters
        ----------
        web_image : np.ndarray
            Line drawn image
        """
        web_image = web_image.astype(np.uint8)  # have to convert grayscale
        _, image_bin = cv2.threshold(web_image, 127,255, cv2.THRESH_BINARY_INV)
        contours, hierarchy = cv2.findContours(image_bin.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
        return contours, hierarchy, image_bin
    
    def __get_circle_radius_center(self, raw_dist):
        '''Get inner circle radius, center coordinates

        Parameters
        ----------
        raw_dist : np.ndarray
            @@@

        Returns
        ----------
        radius : float
            Radius of inner circle
        center : tuple
            Center coordinate of inner circle
        '''
        dist_transform, _ = cv2.distanceTransformWithLabels(raw_dist, cv2.DIST_L2, maskSize=5)
        _, radius, _, center = cv2.minMaxLoc(dist_transform)
        return radius, center
    
    
    def __numbering_colorspace_from_contours(self, 
                                             background_img, 
                                             image_bin, 
                                             contours, 
                                             hierarchy, 
                                             img_lab, 
                                             lab):
        '''looping contours list, and put color index label in each contour.

        Parameters
        ----------
        background_img : np.ndarray
            Background image
        image_bin : np.ndarray
            @@@
        contours : tuple
            @@@
        hierarchy : np.ndarray
            @@@
        img_lab : np.ndarray
            @@@
        lab : np.ndarray
            @@@

        Returns
        ----------
        background_img : np.ndarray
            Image that filled color index string each contours.
        '''
        # TODO: more faster
        for idx in trange(len(contours), file=sys.stdout, desc='Numbering Process'):
            contour = contours[idx]

            # Ignore areas below a certain size
            if cv2.contourArea(contour) < self.NUMBERING_MIN_AREA:
                continue

            chlidren = [i for i, hierarchy_obj in enumerate(hierarchy[0]) if hierarchy_obj[3] == idx]

            raw_dist = np.zeros(image_bin.shape, dtype=np.uint8)
            cv2.drawContours(raw_dist, contour, -1, (255, 255, 255), 1)
            cv2.fillPoly(raw_dist, pts =[contour], color=(255, 255, 255))
            cv2.fillPoly(raw_dist, pts =[contours[i] for i in chlidren], color=(0, 0, 0))

            radius, center = self.__get_circle_radius_center(raw_dist)

            # Ignore radius below a certain length
            if radius < self.NUMBERING_MIN_RADIUS:
                continue

            if center is not None:
                cv2.drawContours(background_img, [contour], -1, (150, 150, 150), 1)

                # 내접원 확인용(주석 풀면 활성화)
                # cv2.circle(img, center, int(radius), (0, 255, 0), 1, cv2.LINE_8, 0)

                # Show the color detected inside the contour
                color_text = self.check_avg_color_inside_colorspace(img_lab, contour, lab)

                center_point = (center[0], center[1])
                self.__set_label_inside_colorspace(background_img, color_text, center_point, radius)
                
        return background_img

 

 

이로서 pypipo 라이브러리의 image processing 수행과정은 모두 끝입니다.

 

컨튜리뷰션

pypipo 오픈소스 컨튜리뷰션에 참여해주실 분은 언제든지 댓글 남겨주십시오

혹은 위에 적어둔 Github Issue에 등록해주셔도 확인이 가능합니다.

반응형