오늘의 코드 리뷰 - unique_colors(img)
오늘은 학부를 갓 졸업한 신입의 코드를 리뷰했다. 지가 만든 게 깃헙 스타를 몇백개 쯤 받았다면서 뽕이 차서는 겁도 없이 마구 싸대는 친구인데, 그렇다는 놈이 코드는 아무리 봐도 거지발싸개 같이 짜 놔서 오늘 날 잡고 신명나게 두들겨 패 줬다.
문제
이미지를 입력받아, 이미지에 존재하는 고유한 색의 집합을 반환하는 함수 unique_colors를 작성하라
unique_colors는 마스크 이미지 관련 로직 개발 시 자주 쓰이는 함수다. 특히 semantic/instance segmentation에서, 정답 마스크 어노테이션을 종종 rgb 마스크로 인코딩하여 마스크 여럿을 하나의 이미지에 저장하는 경우가 종종 있다.
(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]
- 나 - 아니 왜 shape에 3을 제한을 뒀지? 왜 이랬어요?
- 늅 - 그레이스케일 png 이미지를
cv2.imread
하면 가끔 shape이(h,w)
인 이미지가 있더라구요. - 나 - 근데 이러면 터지잖아요?
- 늅 - 이게 빨리 터지니까, 문제 되는 부분을 빠르게 알 수 있어서 좋은 거 아닌가요? 상식적으로, rgb로 인코딩한 마스크를 입력받고 있는데 흑백이 들어오면 안 되죠.
- 나 - 아니죠. 그레이스케일을 막는 게 아니라 그레이스케일에도 대응할 수 있어야죠. 저라면 (h,w)인 입력이 들어온다면 (h,w,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
- 나 - num_unique_colors? 이건 뭐죠? 이름이 왜 이렇죠? 이런 이름이라면 고유한 색의 집합이 아니라 개수를 반환할 거 같은데요?
- 늅 - 하지만 counts를 반환해야 해서 어쩔 수 없었어요.
- 나 - 그 부분도 이상해요. 집합을 반환했으면, 반환값에 len을 호출하면 고유한 색의 개수를 알 수 있잖아요?
- 늅 - 근데 np.unique에서 개수를 반환하는 API가 있어서요. 이걸 쓰면 좀 뭔가 좋은 점이 있지 않을까요?
- 나 - 어떤 점이 좋죠?
- 늅 - ... 글쎄요? 굳이 만들어 놓은 거 보면 counts를 안 쓰는 것보단 쓰는 게 뭔가 좋지 않을까요?
- 나 - ...
- 나 - 제 생각에는
- unique의 API를 따르는 것보다 함수의 이름과 동작을 일치하게 해서 구현을 보지 않아도 되게 하는 게 더 나을 거 같아요. 파이썬 같은 동적 타입 언어에서는 함수와 인자의 이름이 그 무엇보다 중요해요(뭐 요새는 타입도 달긴 하는 거 같던데.. 우린 안 써요)
- 이 코드는 아주 짧지만 복잡complex 해요. 하나의 함수에서 쓸데없이 두가지 일을 같이 해서, 두 로직을 서로 엮고complect있기 때문이에요. 저라면 고유한 색상 개수에 대한 로직을 아예 제거하고, 사용자가 필요하면 알아서 len을 쓰게 할 거에요.
- 늅 - ...
[3]
- 늅 - 생각할 게 참 많네요.
- 나 - 아직 안 끝났어요. [1]이랑도 이어지는 이야기인데, 이 로직을 구현할 때 이 함수가 진정으로 뜻하는 바, 혹은 이 함수가 이룩하는 추상화abstraction에 대해서 좀 더 생각해볼 수 있어요. unique_colors가 정말로 하는 일이 무엇인지 조금만 더 고민해 보면, 훨씬 robust하면서 더욱 많은 경우에 대응 가능한 일반화된 코드를 짤 수 있어요.
- 늅 -
그냥 디버깅용 툴 짜는데 뭔 개소리야 ㅅㅂ무슨 말씀이신가요? - 나 - "색"이라는 게 뭔지 더 생각해봅시다. 그냥 rgb 값일까요? 아니죠? 그레이스케일의 경우에는 색상 값 하나만 있겠죠? 만약에 알파 채널이 있는 png라면 어떨까요? rgba 값이 되겠죠? 그런데 님이 짠 코드를 보세요. c == 3 이라고 assert 하고 있잖아요?
- 늅 - 그건 그러네요.
- 나 - 님이 짠 코드는 디버깅 툴이라 보기에는 너무 특수specific해요. 오직 rgb 이미지에 대해서만 작동하게 되어 있죠. 이보다 더 다양한 상황에서 돌아가게 할 수 있어요. 이미지에 임의의 개수의 채널이 있을 때, 색 이라는 것은 결국 (y,x) 좌표의 모든 채널 값을 담은 배열이에요. 그러니 채널이 하나면 [x], 채널이 rgb 셋이면 [r,g,b], 알파 채널까지 있으면 [r,g,b,a]인거죠.
- 나 - 이렇게 색이라는 개념을 일반화, 추상화해서 생각하고 이를 코드에 녹여낼 수 있어요.
- 나 - 님은 이런 식으로, 추상적으로 사고하는 부분이 부족해요. 그래서 지금 당장의 요구사항을 만족시키는 코드만을 작성한 것이죠. 당장 급하고 빠르게 피처를 쳐내야 하는 상황이나, 쓰고 버릴 실험적인 코드에서는 그럴 수도 있어요. 하지만 이 경우는 두고 두고 쓸 디버깅 툴을 만드는 것이고, 지금은 딱히 급할 것도 없지요.
- 나 - 정말로 robust한 소프트웨어를 만들려면, 지금 받는 입력보다 더 넓고 더 일반적인 입력이 무엇인지 생각을 해야 해요. 즉, rgb 이미지만을 받는 것이 아니라, 임의의 채널을 갖는 이미지에 대해서 작동하는 함수를 짰어야 해요.
[2]
- 단일 책임 원칙 Single Responsibility Principle(SRP): 하나의 모듈(함수)은 하나의 일만 해야 한다
- 복잡함은 개수나 양의 문제가 아니라, 개념이 엮여있는 것이다(32:41, 1:00:44). 프로그래머라면 복잡함과 단순함을 간파할 수 있어야 한다(59:17) - Rich Hickey, Simple Made Easy
- 이름 잘 써라
[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]
- 나 - 흠....
- 늅 - 이제 괜찮은가요?
- 나 - 음. 아니, 마지막으로 하나만 더 합시다. 이건 numpy 쪽을 안 해 봤으면 모를 수도 있는 부분이에요.
- 뉴 -
아 시발 언제 끝나 이거 집에 좀 가자..어떤 건가요? - 나 - 채널 1000개짜리 img를
np.unique(img.reshape((h*w, c)), axis=0)
에 넣어서 호출해 보세요 - 뉴 - 넵
- 나 - ...
- 늅 - ...
- 나 - ...
- 늅 - 아니 이거 왜이렇게 오래 걸려요? 뭐죠?
- 나 - np.unique는 axis=0로 호출하는 경우 이상하게 성능이 낮아요. 사실 이건 안 해봤으면 모르긴 해요. 이건 아까 말한 것들에 비하면 사소한 문제긴 하죠.
[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년 전에 한창 식질머신을 만들던 때의 나다. 난가?
사실 5년 전에는 semantic segmentation을 하던 때라, 채널의 수가 그리 많아질 일이 없었다.
그런데 지금은 instance segmenation을 하면서, 클래스에 속한 instance를 색상으로 구분하여 인코딩 된 데이터를 다루게 되었다. 이걸 one-hot 인코딩으로 변환할 때, 채널이 아주 많은 데이터가 나온다. 그런 데이터를 다루는 유스케이스를 쳐내야 했기 때문에 위와 같은 코드를 쓰긴 했다(특히 성능 최적화의 경우, 채널이 아주 많은 이미지 입력이 없었다면 좀 느리더라도 그냥 썼을 것이다)
하지만 그걸 떠나서도 여러 부분에서 미흡한 코드였고, 어떤 점으로 미흡한지 하나 하나 원칙과 원리를 들어서 설명할 수 있었기에 이런 꽁트 형식으로 셀프 코드 리뷰를 해 보았다.
그래도 근로저를 해서 그런지, 5년 전에 비해 많은 부분이 성장한 거 같아 기부니가 좋다.
특히 포스텔의 법칙, 추상화, 단순함에 대한 건 대부분 근로저에서 배운 것들이다.
맺음말
오늘의 교훈
- 포스텔/Robustness 법칙: 입력은 관대하고 자유롭게, 출력은 엄격하고 보수적으로.
- 항상 추상화를 신경 쓰고, 이름 잘 지어라
- 단일 책임 원칙: 모듈은 하나의 일만 해야 한다