본문 바로가기

인공지능/Python

python mask to polygon, reducing points in polygon

Introduction

2021 인공지능 온라인 경진대회의 hair segmentation 부분에 참여를 했고, 그 과정에서 가장 많이 고생했으면서 새롭게 알았던 부분에 대해 공유를 하고자 이 글을 작성한다.

우리 팀에서 사용한 모델은 torchvision의 mask R-CNN로 mask를 결과물로 줬고, 대회 submission form은 polygon이었다.

여기서 mask는 binary로 제공되지 않고, 각 픽셀 별 확률로 나타나 있기 때문에, 어느정도까지를 mask로 봐야할지 정하는 threshold 또한 최적화를 진행했다.

출력으로 얻는 mask 이미지를 polygon으로 바꿔야했고, submission json 파일크기에 제한이 있어 polygon의 갯수를 줄여야(최적화) 했다.

mask를 polygon으로 바꾸는 방법은 많았고 생각보다 금방 찾았지만, polygon의 개수를 줄이는 작업은 꽤나 고생했다.


Server spec

메인스펙만 간단히 적겠다.

CPU : Intel i9-10900X@3.70GHz

GPU : Titan RTX

RAM : 256GB

OS : Ubuntu 18.04.5LTS (GNU/Linux 5.4.0-74-generic x86_64)


Mask to Polygon

실제로 적용 했던 방법 2가지를 소개하려고 한다.

 

첫번째는 이번 대회에서 처음 써본 imantics 라는 라이브러리이다.

해당 라이브러리는 github에서 cocodataset의 cocoapi issues에서 처음 발견했다.(참고 : https://github.com/cocodataset/cocoapi/issues/39)

import numpy as np
from imantics import Polygons, Mask

# This can be any array
array = np.ones((100, 100))

polygons = Mask(array).polygons()

print(polygons.points)
print(polygons.segmentation)

위 사용법 예시의 polygons.points에서 mask의 polygon을 확인할 수 있다.

 

그리고 실제 대회에서 사용했던 코드 (mask to polygon 부분만 추출)

#numpy 형태로 변경
mask = output[0]['masks'][0,0,:,:].detach().cpu().numpy()

#최적화한 hyperparameter인 Threshold값에 따라 binary mask로 변경 (0 or 255)
mask[mask<Threshold] = 0
mask[mask>=Threshold] = 255

#mask에서 polygon 추출
polygons = Mask(mask).polygons()

#가끔 polygon이 여러개 잡힐때 가장 points의 개수가 많은 polygon사용
if len(polygons.points) > 1: 
    points = polygons.points[0]
    for i in range(len(polygons.points)):
        if len(points) < len(polygons.points[i]):
            points = polygons.points[i]
else :
    points = polygons.points[0]

새롭게 얻은 polygon을 시각화하여 원본 mask와 비교하니 일치하는 것을 확인했다. (실제 실험에 사용한 데이터는 대회 규정 상 외부 유출이 불가함으로 여기에는 코드만 올릴 예정)

전체 Task time(Image loading, model segmentation, mask to polygon, reducing points in polygon, polygon to json)은 test dataset(512x512) 약 1.2만장기준 40분 정도 소요되었다.


두번째는 OpenCV의 findcontours이용했다.

OpenCV는 다뤄본적이 있기 때문에 공식 문서를 참조했다.(https://docs.opencv.org/3.4/d4/d73/tutorial_py_contours_begin.html)

실제 대회에서 사용했던 코드

#numpy 형태로 변경
mask = output[0]['masks'][0,0,:,:].detach().cpu().numpy()

#최적화한 hyperparameter인 Threshold값에 따라 binary mask로 변경 (0 or 255)
mask[mask<Threshold] = 0
mask[mask>=Threshold] = 255

#uint8로 바꾸지 않으면 cv2.threshold함수에서 에러발생
mask=mask.astype(np.uint8)
_, threshold = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
contours,_ = cv2.findContours(threshold, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

#contour가 여러개인 경우 가장 points가 많은 contour 사용
if len(contours) > 1 :
    c = contours[0]
    for i in range(len(contours)):
        if len(c) <  len(contours[i]) :
            c = contours[i]
else :
    c = contours[0]

Contour를 이용해 polygon을 얻은 경우도 시각화하여 원본 mask와 비교하니 일치하는것을 확인했다.

전체 Task time(Image loading, model segmentation, mask to polygon, reducing points in polygon, polygon to json)은 test dataset(512x512) 약 1.2만장기준 10분 정도 소요되었다.


Reducing points in polygon

굉장히 다양한 방법(4개면 많지 않나..)을 생각했었다.

  • concave hull
  • edge 계단화(곡선의 직선화)
  • adjacent points 들의 edge간의 기울기 값의 차이에 따른 point 제거
    • 1-2 point의 기울기와 2-3 point의 기울기가 차이가 매우 적다면 2point 제거(제거하는 기준 threshold 또한 hyperparameter)
  • 단순 2개 값씩 평균(1,2point 평균, 3,4point평균, ...)

결론을 먼저 말하자면 edge 계단화를 채택했다.

이유는 각 방법에 대한 설명과 함께 후술하겠다.


첫번째로 생각했던 concave hull에 대해 간단한 이미지와 함께 설명하겠다.

concave hull(polygon)은 convex하지 않은 polygon이다.

아래 그림의 중앙에 Alpha shape로 나타나는 모양이 concave hull(polygon)과 같은 이미지이다.

같은 points set이어도 Alpha 값에 따라 다른 모양의 polygon을 나타내게 되는데, alpha값이 극단적으로 가게되면 아래 그림의 우측처럼 tree 형태로 나타나게 된다.

 

Alpha shape(https://en.wikipedia.org/wiki/Alpha_shape#/media/File:ScagnosticsBase.svg)

Alpha shape의 2차원에서의 time complexity는 O(nlog n)으로 보통 2~300개의 polygon이 나오는 이번 실험에 사용할만 하다고 판단되어 한번 시도해 보았다.(참고 : https://web.archive.org/web/20110308071257/http://www.mpi-inf.mpg.de/~jgiesen/tch/sem06/Celikik.pdf)
실제 대회에 사용했던 코드

import alphashape
alpha = 0.95 * alphashape.optimizealpha(points)
hull = alphashape.alphashape(points, alpha)
hull_pts = hull.exterior.coords.xy

대회에서 추론 제한시간이 3시간인데 해당 함수는 3시간을 훌쩍 넘어 사용하지 않기로 했다..


두번째로 생각했던 edge 계단화(곡선의 직선화)에 대해 설명하겠다.

3번째의 아이디어인 adjacent points들 간의 기울기 값 차이에 따른 point제거와 비슷하다고 생각되는데, opencv에서 해당 기능을 수행하는 cv2.approxPolyDP라는 함수가 있어서 그대로 가져다가 사용했다.

cv2.approxPolyDP (https://opencv-python.readthedocs.io/en/latest/doc/16.imageContourFeature/imageContourFeature.html)

주어진 곡선/polygon을 근사하여 points의 갯수를 줄여준다.

epsilon 값에 따라 딱딱한 정도(points의 갯수)가 달라진다.

  • epsilon값이 낮을수록 부드러운(원본에 가까운) polygon이 근사
  • epsilon값이 높을수록 딱딱한 polygon이 근사

해당 함수의 결과는 직접 보여주고 싶어서 마스크만 따로 떼다가 차이만 보여주겠다.

우선 ground truth

plt.imshow(mask[int(boxes[1]):int(boxes[3]),int(boxes[0]):int(boxes[2])])

Ground truth


앞서 mask to polygon문단에서 설명한 contour를 이용해 얻은 원본 polygon

총 295개의 polygon points

mask=mask.astype(np.uint8)
_, threshold = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
contours,_ = cv2.findContours(threshold, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

c = contours[1]

coord = []
for i in range(len(c)):
    coord.append((c[i][0][0],c[i][0][1]))
    
img = Image.new('L', (512,512), 0)
ImageDraw.Draw(img).polygon(coord, outline=1, fill=1)
conv = np.array(img)
plt.imshow(conv[int(boxes[1]):int(boxes[3]),int(boxes[0]):int(boxes[2])])

original polygon with 295 points pair


epsilon 계수를 0.001로 설정한 polygon

총 86개의 polygon points

mask=mask.astype(np.uint8)
_, threshold = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
contours,_ = cv2.findContours(threshold, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

c = contours[1]

peri = cv2.arcLength(c, closed=True)
approx = cv2.approxPolyDP(c, epsilon=0.001 * peri, closed=True)

coord = []
for i in range(len(approx)):
    coord.append((approx[i][0][0],approx[i][0][1]))
    
img = Image.new('L', (512,512), 0)
ImageDraw.Draw(img).polygon(coord, outline=1, fill=1)
conv = np.array(img)
plt.imshow(conv[int(boxes[1]):int(boxes[3]),int(boxes[0]):int(boxes[2])])

approx polygon with 86 points pair(epsilon = 0.001)


epsilon 계수를 0.01로 설정한 polygon

총 15개의 polygon points

mask=mask.astype(np.uint8)
_, threshold = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
contours,_ = cv2.findContours(threshold, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

c = contours[1]

peri = cv2.arcLength(c, closed=True)
approx = cv2.approxPolyDP(c, epsilon=0.01 * peri, closed=True)

coord = []
for i in range(len(approx)):
    coord.append((approx[i][0][0],approx[i][0][1]))
    
img = Image.new('L', (512,512), 0)
ImageDraw.Draw(img).polygon(coord, outline=1, fill=1)
conv = np.array(img)
plt.imshow(conv[int(boxes[1]):int(boxes[3]),int(boxes[0]):int(boxes[2])])

apporx polygon with 15 points pair(epsilon = 0.01)


original polygon과 epsilon값을 낮게 설정한 polygon의 모양이 거의 차이가 없음에도 불구하고 polygon points 갯수가 1/3 수준인것을 확인할 수 있다.

따라서 우리는 이번대회에서 해당 방법을 사용해서 문제를 해결했다.


3번째로 사용한 방법은 adjacent points(인접 포인트)의 edge간의 기울기 값의 차이를 기준으로 중앙 point를 제거하는 방법을 사용했다.

  • point1-2 edge의 기울기와 point2-3 edge의 기울기가 차이가 매우 적다면 point 2 제거(제거하는 기준 threshold 또한 hyperparameter)

위 방법을 적용하기 위해서 우선 mask에서 가장자리 부분만 추출했다.

반복문을 사용해 처음 발견한 가장자리에서 8방향으로 뻗어나가면서 해당 부분이 가장자리인지 확인하는 방법을 사용했다.

해당 코드는 극 초반에 시도한 방법이라 현재(대회 끝나고 내용 정리하는 시점)는 남아있지 않아 알고리즘상에서 가장자리임을 확인하는 조건을 적는다.

  • 현재 내 위치의 값이 255이어야함 (내 위치가 마스크에 해당)
  • 내위치를 기준으로 상하좌우[(-1,0),(+1,0),(0,-1),(0,+1)]points의 값중 하나라도 0인 값이 존재
    • 상하좌우가 모두 255인 경우는 마스크 내부에 있다는 의미이므로 가장자리가 아님
    • 가장자리는 상하좌우중 어느 한 point라도 마스크가 아닌부분이 있음
    • 가장자리라고 판단되는 위치를 queue에 저장
  • queue에 저장된 순서대로 꺼내어 edge간 기울기를 비교
  • 기울기 차이가 미미한경우 중앙 point 제거
  • queue가 empty할때까지 진행

시간이 생각보다 오래걸려 이방법은 해보다가 포기했다.


4번째 방법은 단순 평균값 사용이다.

정말 간단하게 polygon points의 갯수를 줄이기 위해 인접한 2개의 points들의 값을 평균하여 사용했다.

2개의 points를 사용하니 points의 갯수가 1/2, 3개의 points를 사용하니 1/3로 됐다.

성능도 생각보다는 나쁘지 않았지만 approxPolyDP에 비해 IOU값이 적게나와 채택하지 않았다.

반복문으로 일정 개수씩 평균하는 작업만 추가했기 때문에 코드는 따로 기술하지 않겠다.


Conclusion

단순히 모델 적용하는 부분보다 이미지 전처리, 모델 fine tuning과 같이 예상하고 있던부분과 mask 후처리, polygon 최적화와 같이 생각지도 못했던 부분에 시간을 생각보다 많이 소요했다.

그 과정에서 다양한 아이디어도 생각해보고 실제 구현도 해보는 재밌는 시간이었다.

다만 대회 기간이 짧았고(2주) 갑자기 대회 외적으로 할 일들이 늘어나서 대회에 시간을 많이 할당하지 못한게 아쉽다.