KUR Creative


오늘의 코드 리뷰 - unique_colors(img)

오늘은 학부를 갓 졸업한 신입의 코드를 리뷰했다. 지가 만든 게 깃헙 스타를 몇백개 쯤 받았다면서 뽕이 차서는 겁도 없이 마구 싸대는 친구인데, 그렇다는 놈이 코드는 아무리 봐도 거지발싸개 같이 짜 놔서 오늘 날 잡고 신명나게 두들겨 패 줬다.

문제

이미지를 입력받아, 이미지에 존재하는 고유한 색의 집합을 반환하는 함수 unique_colors를 작성하라

unique_colors는 마스크 이미지 관련 로직 개발 시 자주 쓰이는 함수다. 특히 semantic/instance segmentation에서, 정답 마스크 어노테이션을 종종 rgb 마스크로 인코딩하여 마스크 여럿을 하나의 이미지에 저장하는 경우가 종종 있다.

resource/rgb-encoded-mask.png
(source: manga109 ©Yoshi Masako)

위 이미지는 Julian의 manga109 어노테이션 데이터셋에 있는 마스크이다. 배경을 흰색(255,255,255)으로, 말풍선 안에 있는 텍스트는 검은색(1,1,1)으로, 그 외 텍스트는 마젠타(255,1,255)로 인코딩해 두었다. 이런 마스크를 다룰 때, 코드나 데이터가 정확한지 확인하기 위해 입출력 이미지에 존재하는 픽셀의 색 집합을 확인해 보는 경우가 많다.

이번에 한번 신입에게 unique_colors를 짜도록 시켜 보았다. 다음은 신입의 코드다.

def shape3ch(img3ch):
    assert len(img3ch.shape) == 3
    h,w,c = img3ch.shape
    assert c == 3
    return h,w,c

def num_unique_colors(img3ch):
    h,w,c = shape3ch(img3ch)

    uniques,counts = np.unique(
        img3ch.reshape((h*w, c)), # flatten to 2d arr
        axis=0, return_counts=True
    )
    return uniques,counts

야! 이게 뭐야!

코드 리뷰

[1]

[1] - 입력은 관대하게, 출력은 엄격하게 - Postel의 법칙
"빠르게 터져야 빠르게 버그를 잡는다"는 말이 있다. 하지만 이 경우에는 해당하지 않는다. 그건 입력이 잘 정제된 비지니스 로직에서나 맞는 말이다. 이 함수는 디버깅용 툴이다. 그래서 입력 데이터를 거르기보다는 더 넓게 받아야 더 robust하고, 더 많은 경우에 적용할 수 있게 된다.

나라면 이런 코드를 짜서 입력 이미지를 (h,w,c)로 변환했을 것이다.

def reshape_hwc(img):
    '''Normalize img shape as (h,w,c). 
    Return hwc img as is, Reshape hw img as hwc.
    NOTE: doesn't copy the img. ret and img share the memory.'''
    h,w,*c_rest = img.shape
    if c_rest:
        c,*rest = c_rest
        if rest: # img.shape = (h,w,c, 1,1, ...)
            return img.reshape((h,w,c))
        else: # img.shape = (h,w,c)
            return img # as-is
    else: # img.shape = (h,w)
        return img.reshape((h,w,1))

사실 (h,w,c, 1,1, ...)에 대응하는 건 별로 쓸모 있는 부분은 아니다. 근데 그냥 넣었다 ㅎ

테스트는 다음처럼 대충 보고 넘길 것이다

if __name__ == '__main__':
    x = reshape_hwc(np.ones((3,5))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 1))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 2))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 3))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 4))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 4, 1))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 4, 1, 1))); print(x, x.shape)
    x = reshape_hwc(np.ones((3,5, 5, 1, 1, 1))); print(x, x.shape)


[2]
이 함수는 numpy.unique를 쓰면 간단히 작성할 수 있다. unique는 ndarray에 존재하는 고유한 값의 집합을 반환한다. (h,w,c) 이미지의 색은 픽셀 값이므로, unique를 바로 적용할 수는 없다. 예를 들어 픽셀 값이 [255,0,0], [0,255,0], [0,0,255]로 이루어진 이미지를 np.unique에 그대로 입력하면 [0, 255]를 반환한다. 입력하는 이미지 배열을 살짝 고쳐서 unique에 넣으면 고유한 색의 집합을 반환할 수 있다.

뭐 로직은 그리 어렵지 않다. 근데 이 새끼 코드의 상태가..

def shape3ch(img3ch):
    assert len(img3ch.shape) == 3
    h,w,c = img3ch.shape
    assert c == 3
    return h,w,c

def num_unique_colors(img3ch):
    h,w,c = shape3ch(img3ch)

    uniques,counts = np.unique(
        img3ch.reshape((h*w, c)), # flatten to 2d arr
        axis=0, return_counts=True
    )
    return uniques,counts

[3]


[2]

[3]


그래서 나라면 이렇게 짰을 것이다

def unique_colors(img):
    '''img can have arbitrary number of channels. 
    A color is a list of channel values of the pixel(= img[y,x,:]). 
    color example: c=3 [r g b], c=4 [r g b a], c=6 [c1 c2 c3 c4 c5 c6]'''
    h,w,c = reshape_hwc(img).shape
    return np.unique(img.reshape((h*w, c)), axis=0)

와! 코드보다 docstring이 더 길다!


[4]

[4]

결론적으로, 최종 코드는 다음과 같다.

def reshape_hwc(img):
    '''Normalize img shape as (h,w,c). 
    Return hwc img as is, Reshape hw img as hwc.
    NOTE: doesn't copy the img. ret and img share the memory.'''
    h,w,*c_rest = img.shape
    if c_rest:
        c,*rest = c_rest
        if rest: # img.shape = (h,w,c, 1,1, ...)
            return img.reshape((h,w,c))
        else: # img.shape = (h,w,c)
            return img # as-is
    else: # img.shape = (h,w)
        return img.reshape((h,w,1))

def unique_axis0(arr):
    '''Equivalent to np.unique(arr, axis=0). Based on numpy.lib.arraysetops._unique1d.
    It is usually faster than np.unique. See https://github.com/numpy/numpy/issues/15713#issuecomment-796394047'''
    arr = np.asanyarray(arr)
    idxs = np.lexsort(arr.T)
    arr = arr[idxs]
    unique_idxs = np.empty(len(arr), dtype=np.bool_)
    unique_idxs[:1] = True
    unique_idxs[1:] = np.any(arr[:-1, :] != arr[1:, :], axis=-1)
    return arr[unique_idxs]

def unique_colors(img):
    '''img can have arbitrary number of channels. 
    A color is a list of channel values of the pixel(= img[y,x,:]). 
    color example: c=4 [r g b a], c=6 [1 2 3 4 5 6]'''
    h,w,c = reshape_hwc(img).shape
    return unique_axis0(img.reshape((h*w, c)))
    #return np.unique(img.reshape((h*w, c)), axis=0)

당사자와는 원만하게 합의를 보았습니다

얘는 누군데 이렇게 두들겨 패도 괜찮나?
이런 공개된 블로그에서 이 정도 수준의 수치 플레이를 해도 되나?
걱정하는 독자분이 있을지도 모르겠다..

그런데 이 신입은 사실 5년 전에 한창 식질머신을 만들던 때의 나다. 난가?

resource/instance-segmentation.webp

사실 5년 전에는 semantic segmentation을 하던 때라, 채널의 수가 그리 많아질 일이 없었다.
그런데 지금은 instance segmenation을 하면서, 클래스에 속한 instance를 색상으로 구분하여 인코딩 된 데이터를 다루게 되었다. 이걸 one-hot 인코딩으로 변환할 때, 채널이 아주 많은 데이터가 나온다. 그런 데이터를 다루는 유스케이스를 쳐내야 했기 때문에 위와 같은 코드를 쓰긴 했다(특히 성능 최적화의 경우, 채널이 아주 많은 이미지 입력이 없었다면 좀 느리더라도 그냥 썼을 것이다)

하지만 그걸 떠나서도 여러 부분에서 미흡한 코드였고, 어떤 점으로 미흡한지 하나 하나 원칙과 원리를 들어서 설명할 수 있었기에 이런 꽁트 형식으로 셀프 코드 리뷰를 해 보았다.

그래도 근로저를 해서 그런지, 5년 전에 비해 많은 부분이 성장한 거 같아 기부니가 좋다.
특히 포스텔의 법칙, 추상화, 단순함에 대한 건 대부분 근로저에서 배운 것들이다.

맺음말

오늘의 교훈

#sw-design설계
kur2401311147Archivekur2404101144