read

YOLOv8 Concrete Crack Detection using Instance Segmentation #5

- About build-up for driving on HoloLens2, labtop, etc


Index


Review

이전 게시글
지난 게시글에서는 본 프로젝트의 목표와 그에 따른 요구사항과 제한 요소에 대한 배경과 타협책을 정하였다. 이번 게시글에서는 변경된 구조에 따른 개발 과정 중 응용 프로그램을 제외한, 근간이 되는 코드들만 리뷰해 보겠다.


Project Struct

project/
|-- models/
|   |-- yolov8n-seg.onnx
|-- yoloseg/
|   |-- __init__.py
|   |-- utils.py
|   |-- YOLOSeg.py
|-- hl2ss.py
|-- hololens_instance_segmentation.py
|-- requirements.txt
|-- README.md
  • yolov8n-seg.onnx
    커스텀 트레이닝 시킨 모델의 .onnx 포맷 파일이다. .onnx가 범용성이 가장 뛰어나 .pt to .onnx로 export하여 생성하였다.

  • __init__.py
    디렉토리를 패키지로 인식할 수 있도록 하는 스크립트이며, 프로젝트가 로드 될 때 가장 먼저 실행되는 파일이다. 나의 경우 utils.py, YOLOSeg.py를 라이브러리로서 사용하기 위하여 생성하였다.

  • utils.py
    모델의 기능이 구현된 스크립트이다. 모델이 인식할 객체 범위를 정의하고, nms, iou 계산, detection, masking 등의 기능 함수가 구현되어 있다.

  • YOLOSeg.py
    모델의 입출력 처리 전체를 관장하며, 모델 로드, 세그먼트 적용, 입력 이미지 로드, 추론, 바운딩 박스, 마스크 처리, 이미지 스케일 등이 포함되어 있음.

  • hl2ss.py
    HoloLens 2 Sensor Streaming 통신이 구현된 스크립트이다.

  • hololens_instance_segmentation.py
    hl2ss.py의 메소드를 활용하여 홀로렌즈와의 통신을 열고, 프레임을 YOLOSeg.py를 활용하여 모델에 처리하여 결과 비디오를 출력한다.

  • requirements.txt 본 프로젝트 구성에 필수적인 라이브러리들이 정리되어 있는 텍스트 파일으로, requirements.txt가 위치한 디렉토리에서 아래 커맨드 명령어를 통해 자동으로 다운할 수 있다.

    $ pip install -r requirements.txt 
    

본 프로젝트의 구조는 위와 같다.
프로젝트 완성에 앞서 가장 중요했던 센서 스트리밍은 유니티 및 c# 지식 부족으로 아래 링크의 sln 파일 및 파이썬 스크립트 중 내게 필요한 PV 통신 일부를 참고하여 구성하였다.
출처 링크


Script Description

hl2ss.py의 경우 전술한 출처 링크를 통해 직접 확인해 볼 수 있으므로, 필수적인 코드만 살펴 볼 예정이며, 본 게시글에서는 응용 프로그램을 제외한 핵심 코드인 yoloseg 패키지 내의 스크립트만 리뷰한다.

YOLOSeg.py

YOLOSeg.py는 욜로 모델의 출력에 따른 바운딩 박스, 마스킹 처리를 위한 스크립트이며 주요 구성은 아래와 같다.

  1. __init__
  2. __call__
  3. initialize_model
  4. segment_objects
  5. prepare_input
  6. inference
  7. process_box_output
  8. extract_boxes
  9. process_mask_output
  10. draw_detection
  11. draw_masks
  12. get_input_details
  13. get_output_details
  14. rescale_boxes
  15. if __name__ == “__main__”:

사전에 구체적으로 주석처리 하였으므로 위 주요 메소드 및 구성 요소에 대하여 코드와 함께 설명하되 느낀점, 주요 매커니즘을 중점으로 기술하겠다.

__init__

# 생성자: 모델 경로, 확률 임계값, IOU 임계값 및 마스크 개수 초기화
    def __init__(self, path, conf_thres=0.7, iou_thres=0.5, num_masks=32):
        self.conf_threshold = conf_thres    # 예측 신뢰도 임계치 초기화
        self.iou_threshold = iou_thres      # IOU 임계값 초기화
        self.num_masks = num_masks          # 마스크 개수

        # Initialize model
        self.initialize_model(path)         # 모델 로드

__init__()은 파이썬에서 지원하는 매직 메소드로, 클래스에 새로운 인스턴스가 생성될 때 동작하는 문법이다.
이를 통해 YOLOSeg.py 스크립트가 실행되면 가장 먼저 초기화를 해줄 수 있도록 생성자로서 동작한다.

각 매개변수는 모델이 위치한 경로, 신뢰도 임계값, iou 임계값, 마스크 수를 의미한다.

__call__

# 객체 호출시 오브젝트 디텍션 및 세그먼트 수행
    def __call__(self, image):
        return self.segment_objects(image)

__call__ 역시 파이썬에서 지원하는 매직 메소드로, 객체를 호출할 때 사용되며, 객체를 함수처럼 사용하고 싶을 때 이를 사용한다.

initialize_model

    def initialize_model(self, path):
        # ONNX 런타임을 이용하여 모델을 초기화, 이때 'CPUExecutionProvider'는 모델이 CPU에서 실행될 것임을 나타냄
        self.session = onnxruntime.InferenceSession(path, providers=['CPUExecutionProvider'])
        self.get_input_details()  # 입력 정보 가져오기
        self.get_output_details()  # 출력 정보 가져오기

모델 구동에 관한 초기화를 담당하는 함수이다. 나의 경우 맥북에서 테스팅을 하기 때문에 CPUExecution으로 설정하여 진행하였다. 모델의 입출력 노드에 대한 정보를 저장하는 함수인 get_input/output_details를 호출한다.

segment_objects

    def segment_objects(self, image):
        input_tensor = self.prepare_input(image)
        # 모델 입력을 위한 변수 초기화. 이미지를 불러와 batch, channels, height, width 4차원 텐서로 변환

        # Perform inference on the image
        outputs = self.inference(input_tensor)  # 모델에 입력하여 결과 리턴 받음

        self.boxes, self.scores, self.class_ids, mask_pred = self.process_box_output(outputs[0])
        # outputs[0]에 저장된 바운딩박스, 예측 확률, 예측 클래스, 마스크 예측 클래스를 불러옴

        self.mask_maps = self.process_mask_output(mask_pred, outputs[1])
        # outputs[0]에서 불러온 마스크 예측 클래스를 토대로, 데이터를 처리하여 마스크 맵을 생성함.
        # 이때 마스크 맵은 적용된 세그먼트의 결과임.

        return self.boxes, self.scores, self.class_ids, self.mask_maps
        # 바운딩 박스, 예측 신뢰도, 예측 클래스, 마스크 맵을 반환

prepare_input 메소드에 이미지를 넘겨 주고 이를 통해 배치, 채널, 높이, 넓이 정보를 모델의 입력 텐서로 초기화 한다.
초기화된 input_tensor를 통해 inference 함수로 모델에 의한 추론을 시작하고, outputs에 저장한다. 모델 추론에 따른 outputs[0]에서 바운딩 박스, 확률, 클래스, 마스크 클래스를 불러와 각 변수에 저장한다.
이를 통해 데이터를 처리하고 마스크맵을 생성한다.
이를 통해 바운딩 박스, 신뢰도, 클래스, 마스크 맵을 반환한다.

prepare_input

    def prepare_input(self, image):
        self.img_height, self.img_width = image.shape[:2] # 이미지 shape에 저장된 높이와 너비를 가져옴

        input_img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # BGR 이미지에서 RGB이미지로 변환

        # Resize input image
        input_img = cv2.resize(input_img, (self.input_width, self.input_height)) # RGB 이미지 사이즈 조정

        # Scale input pixel values to 0 to 1
        input_img = input_img / 255.0   # 픽셀 값 조정
        input_img = input_img.transpose(2, 0, 1)
        # 모델에 입력하기 위해 이미지 차원 변경 기존 차원(높이, 너비, 차원) -> 차원(차원, 높이, 너비)로 변환됨. (2, 0, 1)은 기존 차원의 인덱스

        input_tensor = input_img[np.newaxis, :, :, :].astype(np.float32) # 모델에 입력하기 위해 4차원 텐서로 변경함
        # 기존  3차원 텐서를 4차원으로 변환함. 이에 따라 0번 차원은 batch_size 표현함. default 1 싱글 배치여도 필요
        # 딥러닝 모델은 부동소수점 연산을 사용하므로, float32로의 데이터 타입 변환

        return input_tensor # 결과 리턴

prepare_input()은 모델 입력을 위해 이미지 전처리(학습 단계에서의 전처리와 상이함), 이미지의 shape를 추출하는 함수이다.
BGR 색상의 이미지는 RGB로 순서를 맞추어 주고, 모델 인풋 사이즈에 맞게 이미지를 리사이즈 한다.
픽셀값의 스케일링을 진행하며, (높이, 너비, 차원) 순서의 기존 차원 포맷을 (차원, 높이, 너비)로 트랜스포즈하여 모델의 입력 포맷에 맞추어 준다.
이렇게 인풋 이미지에 대한 (channel, height, width) 차원의 포맷이 추출되는데, 모델은 4차원 텐서를 입력 구조로 가지기에, 새로운 차원을 추가하여 이에 batch_size를 저장한다. 또한 대부분의 딥러닝 모델은 부동소수점 연산을 이용하므로, 32비트 부동소수점 연산을 나타내는 float32의 데이터로 타입을 변환한다.

본 메소드는 모델에 대한 디테일을 필요로 하는 부분이 많아 꽤 어려움을 겪었다. 구현과 개발에 있어서 문제가 되는 대부분의 경우는 정격 포맷에 맞추지 않았거나, 부동소수점 연산을 사용하지 않아서와 같은 허무할 정도로 사소한 문제가 많다. 또 이러한 사실이 작은 디테일이 중요함을 알 수 있는 반증이기도 하다.

글을 작성하며 떠오른 기억으로, 이미지는 3차원인데 모델은 4차원 입력을 기대하니 어쩔 줄 몰라했던 기억이 난다. 또 그러다 “설마 이게 되겠어?”하며 그냥 축을 추가해 주었더니 해결되었던 것도 기억이 난다. 나를 가로막는 큰 문제도 때때로 아주 단순하게 풀릴 수도 있음을 느꼈다. 어렵게 생각하면 어려운 문제고, 쉽게 생각하면 쉬운 문제다. 다만 이를 위해서는 유연한 사고가 필수적이다.

터널 끝 빛만 보고 걸어 간다면, 바로 옆 비상구도 볼 수 없다.

inference

    def inference(self, input_tensor):
        # start = time.perf_counter() # 추론 시작 시간 체크
        outputs = self.session.run(self.output_names, {self.input_names[0]: input_tensor})
        # onnx 런타임 세션으로 실행, output_names는 모델의 출력 노드, input_names는 모델의 입력 노드이며 이에 input_tensor가 지정됨.
        # 해당 함수의 결과를 outputs에 저장, 결국 모델의 추론 결과가 저장됨.

        # print(f"Inference time: {(time.perf_counter() - start)*1000:.2f} ms") # 성능평가를 위한 추론 시간 계산
        return outputs  # 추론 결과 반환

inference()는 모델에 이미지를 입력하고 추론하는 함수이다. 추론 시작 시간을 체크하고 이를 출력하는 코드도 초기 프로젝트 구성 및 간이 테스트에서는 사용하였지만, 비디오 스트리밍 추론으로 넘어가면서 원활한 진행을 위해 주석처리 하였다.
onnx 런타임 세션을 이용하여 추론을 실행하고 outputs에 저장한다.

ai를 하면서 느끼는 건 사람이 하는 게 많이 없다는 것이다. 일반적으로 생각하는 모델 학습 뿐만 아니라, 모델 실행도 사람이 할 수 있는 건 없다. 밥만 주고 먹으라고 말하듯, 데이터를 주고 처리하라고 명령하는 것이 전부이기에 처음에는 많이 당황스러웠다(그렇다고 밥 주고 먹으라고 하는 게 쉬운 것은 아니다). 우리가 살아가는 세상은 정말 기대 이상으로 발전되어 있는 것 같다.

process_box_output

    def process_box_output(self, box_output):
        # box_output에는 모델이 추론한 박스 좌표, 에측 정확도 및 예측 클래스 등의 정보가 저장됨.

        # 모델의 출력 배열 형태로 조정함. 바운딩 박스, 예측 신뢰도 및 예측 클래스 등의 정보를 저장
        predictions = np.squeeze(box_output).T
        # np.squeeze를 통해 불필요한 차원을 제거하여 차원 축소 후 차원을 전치하여 재배열하여 데이터를 처리함.

        # 분류된 결과 정보를 저장함.
        # box_output[1]에는 경계 상자의 x,y,w,h 4개 좌표와 예측 신뢰도, 마스크 예측 값 등이 저장되어 있음.
        # 이에 마스크 4개 좌표를 제거함. 즉 num_classes에는 모델이 예측한 클래스를 저장하게 됨.
        num_classes = box_output.shape[1] - self.num_masks - 4

        # Filter out object confidence scores below threshold
        # 예측 신뢰도의 최고 점수를 가져옴.
        # prediction 배열의 첫 네 개의 값 바운드박스의 좌표에 해당하므로 제외함. axis=1에 따라 행에서 가장 큰 값을 가져옴.
        scores = np.max(predictions[:, 4:4+num_classes], axis=1)

        # 설정된 예측 신뢰도 임계치를 넘는 데이터만 가져옴
        # predictions는 오브젝트의 정보를 필터링 -> conf_threshold를 넘지 못하는 데이터 정보는 모두 삭제
        predictions = predictions[scores > self.conf_threshold, :]
        # scores는 신뢰도 점수만을 필터링 -> conf_threshold를 넘지 못하는 신뢰도 정보도 모두 삭제
        scores = scores[scores > self.conf_threshold]
        # 두 코드라인을 함께 씀으로써 두 배열이 서로 일치하는 데이터만을 가지도록 동기화 됨.

        # 필터링 된 예측 결과가 없다면 0이 채워진 배열 반환
        if len(scores) == 0:
            return [], [], [], np.array([])

        # 모델이 예측한 바운딩 박스 좌표 및 예측 신뢰도를 저장
        box_predictions = predictions[..., :num_classes+4]
        # 모델이 예측한 마스크 좌표 및 예측 신뢰도를 저장
        mask_predictions = predictions[..., num_classes+4:]

        # 행을 따라 박스 예측 신뢰도가 가장 높은 클래스만을 추출
        class_ids = np.argmax(box_predictions[:, 4:], axis=1)

        # 각 객체에 대한 바운딩 박스 좌표를 추출
        boxes = self.extract_boxes(box_predictions)

        # nms는 겹치거나 중복되는 바운딩 박스를 제거함으로써 여러 예측이 동일 객체를 가리키는 것을 방지함.
        # 이에 대한 기준으로 iou_threshold가 적용, 0.5로 설정하였으므로, 50%이상 겹치는 박스는 제거됨.
        indices = nms(boxes, scores, self.iou_threshold)

        # nms 처리가 이루어진 바운딩 박스, 신뢰도, 클래스 종류, 마스크 예측 결과를 반환함.
        return boxes[indices], scores[indices], class_ids[indices], mask_predictions[indices]

process_box_output()은 모델이 추론한 데이터를 통해 box_output을 처리하는 메소드다.
모델 추론의 결과를 predictions에 box_output을 스퀴즈하여 저장한다. 이때 결과는 이후 처리 과정의 효율을 위해 전치된 결과이다.
나도 공부를 하며 작성한 코드들이기에 주석이 상당히 구체적이므로 나머지는 생략해도 될 것 같다.
모델의 출력 결과에 따른 처리 과정이 어려운 것 같지만 사실 형태가 행렬이나 배열일 뿐, 변수에 담긴 데이터를 꺼내 와서 비교하고 저장하는 게 전부이기 때문에 모델의 정격 포맷에 대한 이해만 있으면 충분히 할 수 있는 부분이다.

extract_boxes

    def extract_boxes(self, box_predictions):
        # 모든 행(모델 예측 결과)에 대하여 4열까지의 데이터를 가져옴. 즉 박스 데이터를 모두 가져옴
        boxes = box_predictions[:, :4]

        # Scale boxes to original image dimensions
        boxes = self.rescale_boxes(boxes,
                                   (self.input_height, self.input_width),
                                   (self.img_height, self.img_width))

        # Convert boxes to xyxy format
        boxes = xywh2xyxy(boxes)

        # Check the boxes are within the image
        boxes[:, 0] = np.clip(boxes[:, 0], 0, self.img_width)
        boxes[:, 1] = np.clip(boxes[:, 1], 0, self.img_height)
        boxes[:, 2] = np.clip(boxes[:, 2], 0, self.img_width)
        boxes[:, 3] = np.clip(boxes[:, 3], 0, self.img_height)

        return boxes

extract_boxes()는 모델의 추론 결과로부터 박스의 정보를 추출하는 함수로, process_box_output()에 의해 호출되어 사용된다.

process_mask_output

# 마스크 처리 함수
    def process_mask_output(self, mask_predictions, mask_output):

        # 모델이 예측한 마스크에 대한 정보가 없다면, 빈 배열을 반환
        if mask_predictions.shape[0] == 0:
            return []

        # 마스크 출력을 차원 축소하여 불필요한 정보를 제거함.
        mask_output = np.squeeze(mask_output)

        # Calculate the mask maps for each box
        # 마스크 출력의 차원 정보를 가져옴
        num_mask, mask_height, mask_width = mask_output.shape

        # 마스크 예측값과 모델의 출력값을 행렬 곱셈(@)하여 최종 마스크를 계산함.
        # 이를 이를 위해 모델의 출력값 차원을 재조정함. num_mask는 mask_output 배열의 첫 번째 차원이며, 채널 수를 의미함.
        # -1 매개변수를 통해 mask_output은 num_mask 매개변수를 가지는 행렬로 재배열 됨.
        # 이를 통해 mask_output은 2차원 배열이며, 각 행은 하나의 마스크를 나타내게 됨
        masks = sigmoid(mask_predictions @ mask_output.reshape((num_mask, -1)))

        # 계산된 마스크를 기존의 이미지 크기에 맞게 다시 재조정함. 행렬 계산을 위해 변환 후 다시 재조정한 것임.
        masks = masks.reshape((-1, mask_height, mask_width))

        # 감지된 바운딩 박스 마스크 크기에 맞게 재조정함.
        scale_boxes = self.rescale_boxes(self.boxes,
                                   (self.img_height, self.img_width),
                                   (mask_height, mask_width))

        # 마스크 맵을 초기화
        mask_maps = np.zeros((len(scale_boxes), self.img_height, self.img_width))
        # 블러 처리할 영역의 크기를 정함
        blur_size = (int(self.img_width / mask_width), int(self.img_height / mask_height))

        # 각 바운딩 박스에 대한 마스크맵 생성
        for i in range(len(scale_boxes)):

            # 바운딩 박스의 스케일 조정된 좌표를 계산함.
            # math.floor는 반내림하여 마스크 경계를 명확히 하기 위함. math.ceil은 반올림
            scale_x1 = int(math.floor(scale_boxes[i][0]))   # x1좌표: 바운딩 박스의 좌상단 x좌표
            scale_y1 = int(math.floor(scale_boxes[i][1]))   # y1좌표: 바운딩 박스의 좌상단 y좌표
            scale_x2 = int(math.ceil(scale_boxes[i][2]))    # x2: 바운딩 박스의 우하단 x좌표
            scale_y2 = int(math.ceil(scale_boxes[i][3]))    # y2: 바운딩 박스의 우하단 y좌표

            # 기존 바운딩 박스의 좌표를 계산함.
            x1 = int(math.floor(self.boxes[i][0]))
            y1 = int(math.floor(self.boxes[i][1]))
            x2 = int(math.ceil(self.boxes[i][2]))
            y2 = int(math.ceil(self.boxes[i][3]))

            # 기존과 신규 좌표를 모두 계산하는 것은 스케일 조정된 마스크 좌표를 기존 바운딩 박스의 크기에 맞게 일치시키기 위함.

            # 각 객체의 스케일 조정된 바운딩 박스(마스크) 좌표 scale_y1:scale_y2, scale_x1:scale_x2를 추출
            scale_crop_mask = masks[i][scale_y1:scale_y2, scale_x1:scale_x2]

            # 추출한 각 마스크의 크기를 원본 바운딩 박스의 크기(x2 - x1, y2 - y1)에 맞게 사이즈 조정함.
            # interpolation=cv2.INTER_CUBIC는 사이즈 조정 시에 사용하는 보간법임.
            crop_mask = cv2.resize(scale_crop_mask,
                              (x2 - x1, y2 - y1),
                              interpolation=cv2.INTER_CUBIC)

            # 각 바운딩 박스 크기에 맞게 잘려진 마스크의 경계를 블러 처리하여 부드럽게 함.
            crop_mask = cv2.blur(crop_mask, blur_size)

            # 마스크 영역을 이진화 하여, 마스크 영역을 명확히 함.
            crop_mask = (crop_mask > 0.5).astype(np.uint8)

            # 각 오브젝트의 최종 마스크를 마스크 맵의 해당 위치에 할당함.
            # mask_maps[i, y1:y2, x1:x2은 원본 이미지에 대응하는 영역을 나타냄.
            mask_maps[i, y1:y2, x1:x2] = crop_mask

        # 최종 처리된 마스크맵 반환
        return mask_maps

process_mask_output()은 process_box_output()과 같이 모델의 추론 결과에 따라 mask_output을 처리하는 함수이다.
처음 프로젝트를 시작할 때에 모델의 Task를 Object Detection으로 두고 시작하였기에 Instance Segmentation의 핵심인 마스크에 대해서는 지식이 부족하여 주석이 많아졌다.
본 메소드도 process_box_output()과 같이 차원축소를 이용하는데, 차원축소는 딥러닝과 같은 데이터 분석 및 처리를 요구하는 분야에서 자주 사용된다. 차원축소를 통해서 불필요한 정보를 제거하여 데이터를 정제하고 연산 등의 과정에서의 불필요한 과정을 줄여 고생을 덜 수 있다. 또한 데이터량이 방대할 때에는 차원축소의 여부가 메모리 리소스 등과 같은 자원 활용률에도 영향을 미칠 수 있다.
대부분의 동작은 주석을 통해 알 수 있으며, 그 구조는 process_box_output()과 유사하다.
다만 독특한 것이 마스크 예측값과 모델의 출력값을 행렬 곱셈하여 최종 마스크를 계산한다는 점인데, 이는 마스크 영역에 해당하는 각 픽셀에 대하여 클래스 예측 값과 마스크 맵 결과를 함께 고려하여 최종 마스크를 구성하기 위함이다.
이렇게 생성된 마스크는 행렬 계산을 위해 reshape()하였기 때문에 다시 복원하며, 이미지에 맞게 박스와 마스크 맵 모두를 동기화 하여야 한다.

crop_mask = cv2.resize(scale_crop_mask,
                              (x2 - x1, y2 - y1),
                              interpolation=cv2.INTER_CUBIC)

위 코드 중 ‘interpolation’은 마스크를 다시 리사이즈하는 과정에서 사용되는 보간법을 지정하는 파라미터로, 이미지를 확대하거나 축소하는 경우 발생할 수 있는 계단현상과 노이즈를 줄이기 위해 필수적이다.
또한 ‘cv2.INTER_CUBIC’은 openCV에서 제공하는 보간법 중 ‘쌍삼선형 보간법‘으로 인접 픽셀들의 가중치를 고려하여 이미지를 보간하여 그 품질이 좋지만 많은 계산량으로 속도가 느리다는 단점이 있다. 나는 느리더라도 깔끔한 마스크를 원했기에 이를 사용하였으며, 마스크 맵의 속도가 문제가 생긴다면, ‘선형 보간법‘이나 ‘최근접 이웃 보간법‘으로의 변경을 고려해 볼 수 있을 것이다.

draw_detections

    def draw_detections(self, image, draw_scores=True, mask_alpha=0.4):  // mask_alpha는 마스크의 투명도임.
        return draw_detections(image, self.boxes, self.scores,
                               self.class_ids, mask_alpha) 

draw_detections()는 이미지 위에 추론한 객체의 바운딩 박스, 클래스 종류, 예측 신뢰도를 그리는 함수이다.

draw_masks

    def draw_masks(self, image, draw_scores=True, mask_alpha=0.5):
        return draw_detections(image, self.boxes, self.scores,
                               self.class_ids, mask_alpha, mask_maps=self.mask_maps)

draw_masks()는 draw_detection()의 mask 확장 함수이다.

get_input_details

    def get_input_details(self):
        # 모델의 입력 노드 정보를 저장
        model_inputs = self.session.get_inputs()
        # 입력 노드의 이름을 리스트로 저장함
        self.input_names = [model_inputs[i].name for i in range(len(model_inputs))]

        # 첫 번째 입력 노드의 형태를 가져와서 입력 높이와 너비를 저장함.
        self.input_shape = model_inputs[0].shape
        self.input_height = self.input_shape[2]
        self.input_width = self.input_shape[3]

get_input_details()는 각 노드의 데이터에 접근하여 데이터를 처리하기 위해 필요한 함수로 본 프로젝트에서는 첫 번째 입력 노드에서 입력 높이 및 너비를 저장하고 사용하였다.

get_output_details

    def get_output_details(self):
        # 모델의 출력 노드 정보를 저장
        model_outputs = self.session.get_outputs()

        # 출력 노드의 이름을 리스트로 저장
        self.output_names = [model_outputs[i].name for i in range(len(model_outputs))]

rescale_boxes

@staticmethod
    def rescale_boxes(boxes, input_shape, image_shape):
        # 입력 이미지의 형태를 이용해서 바운딩 박스의 크기를 조정함. 이를 통해 바운딩 박스를 원본 이미지 크기에 맞게 조정할 수 있음.
        input_shape = np.array([input_shape[1], input_shape[0], input_shape[1], input_shape[0]])
        boxes = np.divide(boxes, input_shape, dtype=np.float32)
        # 바운딩 박의 좌표를 입력 이미지 크기로 나누어 정규화함.

        boxes *= np.array([image_shape[1], image_shape[0], image_shape[1], image_shape[0]])
        # 정규화된 바운딩 박스를 다시 원본 이미지 크기에 맞게 스케일함.
        # image_shape = [ '높이', '너비' ] 바운딩 박스의 x1, x2는 너비, y1, y2는 높이에 관한 정보이므로 1, 0, 1, 0

        # 바운딩 박스 리턴
        return boxes

rescale_boxes()는 입력 이미지에 맞게 박스를 스케일링하는 함수로, 이를 스크립트 내에서 라이브러리 함수처럼 사용하기 위해 “@staticmethod” 데코레이터를 추가하였다. 이를 통해 인스턴스 생성 없이 자유롭게 호출하고, 재사용할 수 있다.

if __name__ == __main__:

if __name__ == '__main__':
    # 테스팅 코드
    from imread_from_url import imread_from_url

    model_path = "../models/yolov8n-seg.onnx"

    yoloseg = YOLOSeg(model_path, conf_thres=0.7, iou_thres=0.5)

    img_url = "https://live.staticflickr.com/13/19041780_d6fd803de0_3k.jpg"
    img = imread_from_url(img_url)

    # Detect Objects
    yoloseg(img)

    # Draw detections
    combined_img = yoloseg.draw_masks(img)
    cv2.namedWindow("Model Inference", cv2.WINDOW_NORMAL)
    cv2.imshow("Model Inference", combined_img)
    cv2.waitKey(0)

“if __name__ == __main__:” 구문은 스크립트를 직접 실행할 때에만 포함된 코드를 실행하도록 하는 파이썬 문법으로, YOLOSeg.py를 호출하여 사용하는 hololens_instance_segmentation.py와 같은 외부 파일에 의해 호출될 때에는 실행되지 않는 코드이다. 나는 이를 테스팅을 목적으로 사용하였다.


utils.py

utils.py는 YOLOSeg.py에 의해 사용되는 함수로서, 라이브러리 함수와 같은 구조를 위해 별도로 분리해 놓았다.

  1. nms
  2. compute_iou
  3. xywh2xyxy
  4. sigmoid
  5. draw_detections
  6. draw_masks
  7. 기타 코드

nms

def nms(boxes, scores, iou_threshold):
    # socre를 기준으로 정렬
    sorted_indices = np.argsort(scores)[::-1]

    keep_boxes = []
    while sorted_indices.size > 0:
        # Pick the last box
        box_id = sorted_indices[0]
        keep_boxes.append(box_id)

        # 선택한 상자의 iou를 나머지와 함께 계산
        ious = compute_iou(boxes[box_id, :], boxes[sorted_indices[1:], :])

        # iou에 따라 임계값을 넘는 박스는 제거
        keep_indices = np.where(ious < iou_threshold)[0]

        # print(keep_indices.shape, sorted_indices.shape)
        sorted_indices = sorted_indices[keep_indices + 1]

    return keep_boxes

compute_iou

def compute_iou(box, boxes):
    # 각 박스에 대하여 xmin, ymin, xmax, ymax
    xmin = np.maximum(box[0], boxes[:, 0])
    ymin = np.maximum(box[1], boxes[:, 1])
    xmax = np.minimum(box[2], boxes[:, 2])
    ymax = np.minimum(box[3], boxes[:, 3])

    # 교차되는 면 계산
    intersection_area = np.maximum(0, xmax - xmin) * np.maximum(0, ymax - ymin)

    # 면 통합 계산
    box_area = (box[2] - box[0]) * (box[3] - box[1])
    boxes_area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
    union_area = box_area + boxes_area - intersection_area

    # iou 계산
    iou = intersection_area / union_area

    return iou

위 과정과 같이 각 박스에 관하여 중복되는 면적과 전체 면의 비율을 통해 iou를 계산한다. 이 역시 복잡해 보이지만 간단한 구조로 데이터 타입만 유의하면 될 일이었다.

xywh2xyxy

def xywh2xyxy(src):
    # (x, y, w, h) 차원을 바운딩 박스 좌표인 (x1, y1, x2, y2) 변경
    dest = np.copy(src)
    dest[..., 0] = src[..., 0] - src[..., 2] / 2
    dest[..., 1] = src[..., 1] - src[..., 3] / 2
    dest[..., 2] = src[..., 0] + src[..., 2] / 2
    dest[..., 3] = src[..., 1] + src[..., 3] / 2
    return dest
  • src[…, 0]: 바운딩 박스 중심의 x 좌표
  • src[…, 1]: 바운딩 박스 중심의 y 좌표
  • src[…, 2]: 바운딩 박스의 너비 (width)
  • src[…, 3]: 바운딩 박스의 높이 (height)

즉 (x, y, w, h)는 src[…, n] n=(0, 1, 2, 3)에 순차적으로 대응된다.
또한 (x1, y1, x2, y2)는 (바운딩 박스 좌상단 모서리의 x 좌표, 바운딩 박스 좌상단 모서리의 y좌표, 바운딩 박스 우하단 모서리의 x좌표, 바운딩 박스 우하단 모서리의 y좌표)로서 dest[…, n] n=(0, 1, 2, 3)에 대응된다.

dest[..., 0] = src[..., 0] - src[..., 2] / 2
= (바운딩 박스 좌상단 모서리의 x 좌표) = (바운딩 박스 중심의 x좌표) - (바운딩 박스의 너비의 절반)

ex)
src[..., 0] = 4, src[..., 2] = 4일 때,
dest[..., 0] = 4 - 4/2
dest[..., 0] = 2

위와 같은 방식으로 dest[…, n] n=(0, 1, 2, 3)가 모두 계산된다.

src[…, 0] = 4, src[…, 2] = 4일 때를 예로 들면, 바운딩 박스 중심의 x 좌표 = (4,y), 바운딩 박스의 너비 = 4이다. 따라서 바운딩 박스 좌상단의 x좌표는 = 바운딩 박스 중심의 x좌표 - (바운딩 박스의 너비/2)
dest[…, 0] = src[…, 0] - src[…, 2]/2
4-4/2 = 2이다.

위와 같은 방식으로, dest[…, n] n=(0, 1, 2, 3)이 모두 계산 되어 아래와 같은 데이터가 dest에 저장된다.

    dest[..., 0] = 바운딩 박스의 좌상단 x좌표
    dest[..., 1] = 바운딩 박스의 좌상단 y좌표
    dest[..., 2] = 바운딩 박스의 우하단 x좌표
    dest[..., 3] = 바운딩 박스의 우하단 Y좌표
    dest = {(바운딩 박스의 좌상단 x좌표), (바운딩 박스의 좌상단 y좌표), (바운딩 박스의 우하단 x좌표), (바운딩 박스의 우하단 Y좌표)}

sigmoid

    def sigmoid(x):
    return 1 / (1 + np.exp(-x))

sigmoid
위 그림은 시그모이드 함수식이다. 함수식 그대로 구현하였으며, 시그모이드 함수가 필요할 때 호출되어 사용된다.
나의 경우 crack과 normal만 구분하는 이진 분류였고, 확률을 0 ~ 1사이로 출력한다는 점에서 데이터 가공에 시그모이드 함수가 좋다고 판단하였다.

draw_detections

본 함수는 YOLOSeg.py에서 호출하여 사용하는 동명의 함수의 구현 부분이다.

    def draw_detections(image, boxes, scores, class_ids, mask_alpha=0.3, mask_maps=None):
    img_height, img_width = image.shape[:2]
    size = min([img_height, img_width]) * 0.0006
    text_thickness = int(min([img_height, img_width]) * 0.001)

    mask_img = draw_masks(image, boxes, class_ids, mask_alpha, mask_maps)

    for box, score, class_id in zip(boxes, scores, class_ids):
        color = colors[class_id]

        x1, y1, x2, y2 = box.astype(int)

        cv2.rectangle(mask_img, (x1, y1), (x2, y2), color, 2)

        label = class_names[class_id]
        caption = f'{label} {int(score * 100)}%'
        (tw, th), _ = cv2.getTextSize(text=caption, fontFace=cv2.FONT_HERSHEY_SIMPLEX,
                                      fontScale=size, thickness=text_thickness)
        th = int(th * 1.2)

        cv2.rectangle(mask_img, (x1, y1),
                      (x1 + tw, y1 - th), color, -1)

        cv2.putText(mask_img, caption, (x1, y1),
                    cv2.FONT_HERSHEY_SIMPLEX, size, (255, 255, 255), text_thickness, cv2.LINE_AA)

    return mask_img

Parameters

  • image
    detection 처리할 이미지로, 본 프로젝트에선 실시간 비디오 스트리밍으로, 비디오 프레임이 전달된다.
  • boxes
    바운딩 박스들의 좌표가 저장되어 있다.
  • scores
    탐지된 객체의 신뢰도 확률이 저장되어 있다.
  • class_ids
    각 객체의 클래스 id로, 클래스 이름을 참조한다.
  • mask_alpha
    마스킹 되는 영역의 투명도 파라미터로, 0-1 사이 값이며, 0.3으로 초기화 되어 있으므로, 선택적으로 매개변수를 전달하여도 된다.
  • mask_maps=None
    각 객체에 대한 마스크 정보를 담고 있다.
    for box, score, class_id in zip(boxes, scores, class_ids):
    ~~

본 함수의 핵심은 위에 해당하는 영역이므로, 해당 부분을 집중적으로 살펴 보겠다.
매개변수로 전달 받은 boxes, scores, class_ids는 배열으로 여러 데이터의 집합이다. 따라서 해당 데이터에서 객체 각각의 데이터를 담기 위해 위와 같은 문법으로 Box, scores, class_id 변수에 데이터를 추출한다. 따라서 for 구문의 첫 번째 턴에서는 boxes[0], scores[0], class_ids[0]이, 두 번째 턴에서는 boxes[1], scores[1], class_ids[1]이 추출되고, 구문 내에서 이를 처리하여 모든 객체에 대한 draw_detections이 완료된다.

    color = colors[class_id]
    x1, y1, x2, y2 = box.astype(int)

    cv2.rectangle(mask_img, (x1, y1), (x2, y2), color, 2)

각 객체의 박스에서 좌표를 추출하고, OpenCV의 rectangle() 함수를 이용해서 박스를 그리고, 클래스에 따라 색상을 다르게 지정하는 코드이다.

    label = class_names[class_id]
    caption = f'{label} {int(score * 100)}%'
    (tw, th), _ = cv2.getTextSize(text=caption, fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=size, thickness=text_thickness)
    th = int(th * 1.2)
    cv2.rectangle(mask_img, (x1, y1), (x1 + tw, y1 - th), color, -1)
    cv2.putText(mask_img, caption, (x1, y1), cv2.FONT_HERSHEY_SIMPLEX, size, (255, 255, 255), text_thickness, cv2.LINE_AA)

복잡해 보일 수 있으나, label 변수에 각 객체의 클래스 이름을 저장하고, caption에 클래스 이름과 신뢰도 확률의 백분율을 저장한다.
이하의 코드는 만든 텍스트 폰트 등을 설정하고, 바운딩 박스에 배치하는 코드이다.

draw_masks

아래는 draw_detections 함수에서 호출하여 사용하였던 draw_mask 함수이다.

    def draw_masks(image, boxes, class_ids, mask_alpha=0.3, mask_maps=None):
    mask_img = image.copy()

    for i, (box, class_id) in enumerate(zip(boxes, class_ids)):
        color = colors[class_id]

        x1, y1, x2, y2 = box.astype(int)

        if mask_maps is None:
            cv2.rectangle(mask_img, (x1, y1), (x2, y2), color, -1)
        else:
            crop_mask = mask_maps[i][y1:y2, x1:x2, np.newaxis]
            crop_mask_img = mask_img[y1:y2, x1:x2]
            crop_mask_img = crop_mask_img * (1 - crop_mask) + crop_mask * color
            mask_img[y1:y2, x1:x2] = crop_mask_img

    return cv2.addWeighted(mask_img, mask_alpha, image, 1 - mask_alpha, 0)

전체적인 동작 구조는 draw_detections와 거의 동일하므로, 다른 부분만 보도록 하겠다.

    mask_img = image.copy()

마스크를 그리기 위하여 원본 이미지를 복사한다.

    if mask_maps is None:
            cv2.rectangle(mask_img, (x1, y1), (x2, y2), color, -1)

draw_masks는 draw_detections 함수 내에서 호출되어 사용되는데, draw_detections 함수의 매개변수였던 mask_maps가 None으로 초기화 되어 있었기에 추가적으로 mask_maps를 전달해 준 것이 아니라면, 위 코드가 동작하여 바운딩 박스 전체에 클래스에 따른 색상을 채우게 된다.

    crop_mask = mask_maps[i][y1:y2, x1:x2, np.newaxis]
    crop_mask_img = mask_img[y1:y2, x1:x2]
    crop_mask_img = crop_mask_img * (1 - crop_mask) + crop_mask * color
    mask_img[y1:y2, x1:x2] = crop_mask_img

저장된 마스크맵이 전달되는 경우에는 보다 정밀한 마스크를 그릴 수 있는데, 그 과정은 아래와 같다.

  1. mask_maps의 배열의 0번 인덱스 부터 바운딩 박스를 추출한다. 이떄 2차원 배열인 마스크를 RGB 연산 수행을 위해 차우너 추가로 3차원으로 만들어 준다.
  2. 추출한 이미지에서 바운딩 박스의 영역을 선택한다.
  3. 마스크를 적용한다.

이 과정의 핵심 과정은 아래와 같다.

crop_mask_img = crop_mask_img * (1 - crop_mask) + crop_mask * color

  • crop_mask_img * (1 - crop_mask)
    crop_mask_img에서 마스크 값이 0인 부분, 즉 바운딩 박스 내에 객체가 아닌 부분은 이미지 원본의 색상을 유지하게 해야 한다. 따라서 마스크의 반전한 (1 - crop_mask)를 사용하여, 마스크가 0인 부분은 1으로 원본 색상과 곱해지게 되므로, 바운딩 박스 내 객체가 아닌 부분은 원본 이미지와 같은 이미지를 띄게 된다.
  • *crop_maskcolor**
    crop_mask*color는 마스크 값이 1인 부분, 즉 객체가 위치한 부분에 지정된 컬러를 입히는 과정으로, 이를 통해 객체 영역에 색상이 칠해져 마스킹이 된다.

이 두 가지를 더함으로써 바운딩 박스 내에서 객체 영역은 클래스에 따른 지정 색상으로, 그 외 영역은 원본 이미지와 같은 색상을 유지하게 된다.

최종적으로 해당 이미지를 원본 이미지의 바운딩 박스 영역에 복사하는 방식으로, 원본 이미지의 바운딩 박스 위에 마스킹된 바운딩 박스가 얹혀져 있는 구조를 하게 된다.


정직원으로 채용된 것은 아니었지만 회사를 나오고 다시 학교 생활에 집중하다 보니, 어수선하기도 바쁘기도 하여 2024-02-01에 작성하기 시작한 게시글을 오늘인 2024-04-11에 완전히 작성하게 되었다.
이 시리즈도 이제 두 개 정도의 게시글을 다 쓰게 되면 끝날 것 같으니, 이번 시험 기간에 공부가 손에 안 잡히면 글쓰며 머리를 식혀볼까 한다.


시작했으면 끝은 봐야지!


Profile

Seong Hun KIM

Student
Dept. of Computer Science Engineering | Yeungnam University, Repulic of Korea

yu signature

Phone 010 - 6685 - 1140
Mail tgh7544@naver.com
LinkTree https://linktr.ee/HoonC_corgi

Blog Logo

HoonC-corgi

HoonC-corgi


Published

Image

HoonC-corgi's Blog

해가 지는 곳따라 걷다 보면, 그게 내 기쁨이어라.

목록으로 돌아가기