본문으로 건너뛰기

[ultralytics] Ultralytics Plotting 최적화: PIL 기반 시각화 속도를 최대 15배 향상시킨 비결

PR 링크: ultralytics/ultralytics#27014 상태: Merged | 변경: +129 / -141

들어가며

딥러닝 모델의 학습 및 추론 과정에서 시각화는 결과 분석과 디버깅에 필수적인 요소입니다. Ultralytics YOLO 라이브러리는 다양한 작업(Detection, Segmentation, Pose Estimation 등)에서 예측 결과를 이미지나 비디오에 시각화하는 기능을 제공합니다. 특히, Results.plot(pil=True) 옵션이나 학습/검증 시 사용되는 모자이크 이미지(plot_images) 생성 시, PIL(Pillow) 라이브러리를 활용한 시각화는 유연성을 제공하지만 때로는 성능 병목 현상을 야기할 수 있었습니다.

이번 PR(ultralytics 8.4.201)은 이러한 PIL 기반 시각화 과정의 비효율성을 개선하여, 특정 작업에서는 최대 15배까지 속도를 향상시키는 놀라운 성과를 달성했습니다. 본 글에서는 이 PR이 어떻게 이러한 성능 향상을 이끌어냈는지, 코드 변경 사항을 중심으로 심층적으로 분석하고 그 원리를 파헤쳐 보겠습니다.

코드 분석

이번 최적화는 주로 ultralytics/utils/plotting.py 파일에서 이루어졌습니다. 핵심은 텍스트 렌더링, 이미지 복사, 픽셀 단위 연산의 비효율성을 제거하는 데 있습니다.

1. Annotator 클래스의 내부 구조 변경

기존 Annotator 클래스는 pil=True 옵션에 따라 PIL 이미지 객체와 NumPy 배열(BGR)을 번갈아 사용하며 내부 상태를 관리했습니다. 이 과정에서 불필요한 이미지 형식 변환과 복사가 발생했습니다. 개선된 Annotator는 모든 모드에서 BGR NumPy 배열을 기본 이미지 버퍼로 유지하며, pil=True 옵션은 텍스트 렌더링에만 TrueType 폰트를 사용하도록 변경되었습니다. 즉, 모양(shapes), 마스크(masks), 키포인트(keypoints) 등은 일관되게 cv2/NumPy를 사용하여 그려지고, PIL은 텍스트 렌더링에만 관여하게 됩니다.

Before (개념적): Annotator는 pil 플래그에 따라 내부 이미지 표현(PIL Image vs NumPy array)과 그리기 로직을 동적으로 변경했습니다.

After: Annotator는 항상 BGR NumPy 배열을 내부 이미지로 유지합니다. pil=True는 텍스트 렌더링 방식에만 영향을 미칩니다. 모양, 마스크, 키포인트는 항상 cv2/NumPy를 사용합니다.

# Before (개념적 - 실제 diff는 더 복잡함)
class Annotator:
    def __init__(self, im, pil=False, ...):
        if pil:
            self.im = Image.fromarray(im) # PIL Image로 변환
            self.draw = ImageDraw.Draw(self.im)
            # ... PIL 폰트 로딩 ...
        else:
            self.im = im # NumPy array 유지
            # ... cv2 관련 설정 ...

    def box_label(self, box, label):
        if self.pil:
            # PIL을 이용한 박스 및 텍스트 그리기
            self.draw.rectangle(...) 
            self.draw.text(...) 
        else:
            # cv2를 이용한 박스 및 텍스트 그리기
            cv2.rectangle(...)
            cv2.putText(...)
# After (ultralytics/utils/plotting.py)
class Annotator:
    def __init__(self, im, pil=False, ...):
        # ... (이미지 전처리 및 NumPy 변환) ...
        self.lw = ...
        if self.pil:  # TrueType text on a BGR copy of the image, all other drawing uses cv2/NumPy
            self.im = np.array(im, order="C") # BGR NumPy array 유지
            size = font_size or max(round(sum(im.shape[:2]) / 2 * 0.035), 12)
            self.font = _font("Arial.Unicode.ttf" if non_ascii else font, size)
        else:  # draw in place on the caller's BGR image
            # ... (cv2 관련 설정) ...
            self.im = im # BGR NumPy array 유지

    def box_label(self, box, label: str = "", color: tuple = (128, 128, 128), txt_co
        # ... (cv2를 이용한 박스 그리기) ...
        cv2.polylines(self.im, [np.asarray(box, dtype=int)], True, color, self.lw) if multi_points else cv2.rectangle(
            self.im, p1, (int(box[2]), int(box[3])), color, thickness=self.lw, lineType=cv2.LINE_AA
        )
        if label:
            if self.pil:
                patch = _label_patch(self.font, label, tuple(color), tuple(txt_color))
                h, w = patch.shape[0] - 1, patch.shape[1] - 1  # text height, width
            else:
                w, h = cv2.getTextSize(label, 0, fontScale=self.sf, thickness=self.tf)[0]  # text width, height
                h += 3  # add pixels to pad text
            # ... (텍스트 위치 계산 및 그리기) ...
            if self.pil:
                self._draw(p1[0], p1[1] - h if outside else p1[1], patch)
            else:
                # ... cv2 putText ...

2. 텍스트 렌더링 최적화

이전 코드에서는 FreeType 라이브러리를 사용하여 각 레이블을 측정하고 렌더링하는 과정을 반복했습니다. 이는 plot_images 함수의 약 75% 시간을 차지할 정도로 큰 병목이었습니다. 개선된 코드에서는 다음과 같은 전략을 사용합니다:

  • 폰트 캐싱: _font 함수에 @lru_cache(maxsize=8) 데코레이터를 적용하여, 동일한 폰트 이름과 크기에 대한 폰트 로딩을 한 번만 수행하도록 캐싱합니다. 폰트 로딩은 비용이 높은 작업이므로, 이를 캐싱하는 것은 상당한 성능 향상을 가져옵니다.
  • 레이블 패치 캐싱: _label_patch 함수에 @lru_cache(maxsize=256) 데코레이터를 적용하여, 동일한 텍스트, 배경색, 글자색 조합의 레이블은 미리 렌더링된 이미지 패치(patch)를 재사용합니다. 반복되는 레이블의 경우, FreeType을 통한 텍스트 측정 및 렌더링 과정을 건너뛰고 단순히 캐시된 패치를 복사하는 방식으로 동작합니다. 이는 O(1) 복사 작업으로 줄어듭니다.
  • 텍스트 마스크 활용: _text_mask 함수는 텍스트를 알파 마스크로 렌더링하며, 이는 이후 이미지 블렌딩에 사용될 수 있습니다. pil=True 모드에서 텍스트는 이 방식을 통해 렌더링됩니다.
  • ASCII 텍스트 최적화: _rtl 함수 내에서 text.isascii() 체크를 추가하여, ASCII 문자열의 경우 불필요한 아랍어 텍스트 처리 로직을 O(1) 시간에 건너뛰도록 합니다. 이는 일반적인 영어 레이블의 처리 속도를 높입니다.
# Before (개념적)
def draw_label_with_freetype(text, font_size, color):
    # 1. FreeType으로 텍스트 크기 측정
    width, height = freetype.get_size(text, font_size)
    # 2. 새로운 이미지 생성
    img = Image.new('RGBA', (width, height), color)
    # 3. FreeType으로 텍스트 렌더링
    freetype.render(text, img, (0, 0), ...)
    # ... (이미지 블렌딩) ...
    return rendered_text_image
# After (ultralytics/utils/plotting.py)
@lru_cache(maxsize=8)
def _font(name: str, size: int):
    # ... (폰트 로딩 및 캐싱) ...
    try:
        font = ImageFont.truetype(str(check_font(name)), size)
    except Exception:
        font = ImageFont.load_default()
    # ... (Pillow 버전 호환성 처리) ...
    return font

@lru_cache(maxsize=256)
def _label_patch(font, text: str, color: tuple, txt_color: tuple) -> np.ndarray:
    """Return an opaque (h + 1, w + 1, 3) uint8 box label, cached so repeated labels skip FreeType and blending."""
    text = _rtl(text) # RTL 처리
    w, h = font.getsize(text) # 폰트 크기 얻기 (캐시된 폰트 사용)
    im = Image.new("RGB", (w + 1, h + 1), color)  # 배경 이미지 생성
    ImageDraw.Draw(im).text((0, 0), text, fill=txt_color, font=font)
    return np.asarray(im) # NumPy 배열 반환

# Annotator.box_label 내부
if self.pil:
    patch = _label_patch(self.font, label, tuple(color), tuple(txt_color))
    # ... (계산된 위치에 patch 그리기) ...
    self._draw(p1[0], p1[1] - h if outside else p1[1], patch)

3. 이미지 복사 및 픽셀 단위 연산 최적화

기존 코드에서는 마스크, 시맨틱 마스크, 깊이 맵 등을 처리할 때마다 전체 이미지를 NumPy 배열로 복사하고 다시 PIL 이미지로 변환하는 과정이 반복되었습니다. 이는 특히 plot_images 함수에서 여러 타일을 처리할 때 상당한 오버헤드를 발생시켰습니다. 또한, 각 인스턴스 마스크를 개별적으로 블렌딩하거나, 시맨틱 마스크의 np.unique 연산, 깊이 맵의 np.percentile 연산 등이 픽셀 단위로 수행되어 비효율적이었습니다.

개선된 코드에서는 다음과 같은 변경이 이루어졌습니다:

  • plot_images 최적화:

    • plot_images 함수는 이제 그려질 이미지들만 uint8 형식으로 변환하고, 각 타일의 인스턴스 마스크는 인덱스 맵을 사용하여 한 번만 블렌딩합니다. 이는 전체 프레임 복사 및 반복적인 블렌딩 작업을 제거합니다.
    • class conf 레이블에서 클래스 이름과 신뢰도 값 사이의 공백이 복원되었습니다 (person 0.9 형식).
  • 시맨틱 마스크 처리: semantic_mask 함수는 이제 클래스 ID를 직접 사용하여 팔레트를 인덱싱합니다. 이전에는 np.unique와 np.searchsorted를 모든 픽셀에 대해 수행하여 비효율적이었습니다.

  • 깊이 맵 색상화: colorize_depth 함수는 이제 약 64,000개의 스트라이드 샘플에서 백분위수(percentile)를 추정합니다. 이전에는 모든 픽셀에 대해 정렬(sorting)을 수행하여 비효율적이었습니다. 리뷰 댓글에서 지적된 희소(sparse) 깊이 맵 처리 문제도 이 샘플링 방식을 개선하여 해결했습니다. 리뷰어는 희소 깊이 맵에서 백분위수 계산 시 pool이 비어있을 수 있는 엣지 케이스를 지적했고, PR 작성자는 pool[:: max(1, pool.size // 2**16)]와 같이 유효한 값에서 샘플링하도록 수정하여 이를 해결했습니다.

  • 불필요한 함수 제거: Annotator.fromarray와 Annotator.draw 함수는 PIL 형식 변환을 위해 존재했으나 사용되지 않아 제거되었습니다.

# Before (colorize_depth - 개념적)
def colorize_depth(d, vmin, vmax):
    # ...
    pool = v[valid]
    if mode == "disparity":
        lo, hi = np.percentile(pool, (2, 98)) if pool.size else (0.0, 1.0) # 모든 유효 픽셀에 대해 percentile 계산
    # ...
# After (colorize_depth - ultralytics/utils/plotting.py)
def colorize_depth(
    d: np.ndarray, vmin: float | None = None, vmax: float | None = None, cmap: str = "jet", mode: str = "depth"
) -> np.ndarray:
    # ...
    v = np.where(valid, 1.0 / np.where(valid, d, 1.0), 0.0) if mode == "disparity" else d
    if vmin is None or vmax is None:
        pool = v[valid]
        if mode == "disparity":  # ~64k evenly spaced valid values estimate the percentiles, also for sparse maps
            lo, hi = np.percentile(pool[:: max(1, pool.size // 2**16)], (2, 98)) if pool.size else (0.0, 1.0) # 샘플링된 값으로 percentile 추정
        else:
            lo, hi = (float(pool.min()), float(pool.max())) if pool.size else (0.0, 1.0)
        vmin = lo if vmin is None else vmin
        vmax = hi if vmax is None else vmax
    # ... (색상 맵 적용)
# Before (plot_images - 개념적)
def plot_images(images):
    # ...
    for img in images:
        # PIL 이미지로 변환
        pil_img = Image.fromarray(img)
        # 마스크 처리 (전체 이미지 복사 및 블렌딩 반복)
        for mask in masks:
            np_mask = np.array(mask) # NumPy 변환
            # ... 픽셀 단위 블렌딩 ...
        # ... (다른 오버레이 처리)
# After (plot_images - ultralytics/utils/plotting.py)
def plot_images(im, batch, **kwargs):
    # ...
    # Convert only plotted images to uint8 on their own device
    im = batch['img'].cpu().numpy().astype(np.uint8)
    # ...
    # Blend each tile's instance masks once from an instance-index map
    # ... (인덱스 맵 기반 효율적 블렌딩)
    # ... (pose or segmentation tile in place)
    # ... (Restore space in class conf labels)

4. 버전 업데이트

PR은 라이브러리 버전을 8.4.200에서 8.4.201로 업데이트했습니다. 이는 기능 변경 자체보다는 릴리스 관리의 일부입니다.

--- a/ultralytics/__init__.py
+++ b/ultralytics/__init__.py
@@ -1,6 +1,6 @@
 # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
 
-__version__ = "8.4.200"
+__version__ = "8.4.201"
 
 import importlib
 import os

왜 이게 좋은가?

이 PR의 가장 큰 장점은 압도적인 성능 향상입니다. 제공된 벤치마크 결과에 따르면, 다양한 작업에서 PIL 기반 시각화 속도가 크게 개선되었습니다.

Task Results.plot(pil=True), real predictions plot_images, 16 images
detect 3.3 → 0.5 ms (6.6x) 36 → 18 ms (2x)
segment 9.9 → 5.4 ms (1.8x) 290 → 52 ms (5.6x)
semantic 10.4 → 4.9 ms (2.1x) 92 → 40 ms (2.3x)
depth 8.3 → 3.0 ms (2.8x) 102 → 43 ms (2.4x)
classify 3.7 → 1.0 ms (3.7x) 20 → 19 ms (1.1x)
pose 7.1 → 0.7 ms (10x) 466 → 31 ms (15x)
obb 2.5 → 0.4 ms (6x) 48 → 18 ms (2.7x)

특히 Pose Estimation 작업의 plot_images는 15배, Detection 작업의 Results.plot(pil=True)는 6.6배 빨라졌습니다. 이는 실시간 애플리케이션이나 대규모 데이터셋 처리 시 상당한 이점을 제공합니다. 예를 들어, 비디오 스트리밍이나 대량의 이미지 배치 처리가 훨씬 더 효율적으로 이루어질 수 있습니다.

일반적인 교훈:

  1. 병목 지점 식별 및 집중: 프로파일링을 통해 텍스트 렌더링, 이미지 형식 변환, 픽셀 단위 연산이 주요 병목임을 정확히 파악하고, 이 부분에 최적화 노력을 집중하는 것이 효과적입니다.
  2. 캐싱 전략 활용: 반복적으로 사용되는 리소스(폰트)나 계산 결과(레이블 패치)는 캐싱을 통해 재사용함으로써 불필요한 연산을 줄일 수 있습니다. @lru_cache와 같은 도구는 이를 쉽게 구현하게 해줍니다.
  3. 내부 표현 통일: 다양한 모드(PIL vs cv2)에서 내부 데이터 표현을 일관되게 유지하고, 필요한 경우에만 최소한의 변환을 수행하는 것이 좋습니다. 이 PR에서는 BGR NumPy 배열을 기본으로 사용한 것이 좋은 예입니다.
  4. 라이브러리별 강점 활용: PIL은 텍스트 렌더링에 강점이 있고, OpenCV(cv2)는 이미지 처리 및 모양 그리기에 빠릅니다. 각 라이브러리의 장점을 조합하여 사용하는 하이브리드 접근 방식이 성능과 유연성을 모두 잡을 수 있습니다.
  5. 엣지 케이스 처리: 리뷰 과정에서 지적된 희소 깊이 맵의 백분위수 계산 문제와 같이, 일반적인 경우 외의 엣지 케이스를 고려하여 알고리즘을 견고하게 만드는 것이 중요합니다. 이는 코드의 신뢰성을 높입니다.

결론

이번 Ultralytics PR은 단순한 코드 수정이 아니라, 컴퓨터 비전 라이브러리에서 시각화 성능이 얼마나 중요한지를 다시 한번 보여주는 사례입니다. 텍스트 렌더링 최적화, 불필요한 이미지 복사 제거, 효율적인 알고리즘 적용 등을 통해 다양한 작업에서 놀라운 속도 향상을 달성했습니다. 특히, 캐싱 전략과 각 라이브러리의 장점을 활용하는 하이브리드 접근 방식은 다른 프로젝트에서도 참고할 만한 좋은 설계 패턴입니다. 이러한 최적화를 통해 Ultralytics YOLO는 더욱 빠르고 효율적인 개발 및 추론 환경을 제공하게 되었습니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글