← 전체 과정으로 돌아가기
학생용 교재 · Raspberry Pi 4 · Raspbian Buster · nano 복사·붙여넣기

라즈베리파이 MobileNetV2 CNN으로 사물 인식률 측정하기

robogram.net 이미지셋으로 가벼운 사물 인식 모델을 만들고 CSI 카메라 또는 USB 웹캠으로 실시간 인식과 인식률 측정을 진행합니다.

오늘 만들 프로그램

1. 이미지셋 준비사물별 폴더에 학습 이미지를 정리합니다.
2. 모델 학습OpenCV와 NumPy로 각 학습 이미지의 특징을 저장하고 클래스 균형 k-NN으로 비교합니다.
3. 정해진 횟수로 측정목표 사물을 한 번씩 보여주고 맞힌 횟수로 최종 인식률을 계산합니다.
중요: 화면의 %는 확정 확률이 아니라 학습 이미지와 현재 사물 영상의 유사도입니다.

1단계. 라즈베리파이 환경 확인

OS·카메라·Python 확인 명령어
cat /etc/os-release
which raspistill
vcgencmd get_camera
python3 -c "import cv2, numpy; print('OpenCV:', cv2.__version__); print('NumPy:', numpy.__version__)"
정상 기준은 /usr/bin/raspistill, supported=1 detected=1, OpenCV와 NumPy 버전이 출력되는 것입니다.

2단계. 실습 폴더 만들기

라즈베리파이 실습 폴더 생성 명령어
mkdir -p ~/object_recognition_raspi_app/obj_data
mkdir -p ~/object_recognition_raspi_app/model
cd ~/object_recognition_raspi_app
pwd

마지막 줄이 /home/pi/object_recognition_raspi_app이면 정상입니다.

3단계. robogram.net 이미지셋 준비

  1. 라즈베리파이 Chromium에서 robogram.net 데이터 저장소에 접속합니다.
  2. 수업에서 안내받은 이미지셋 ZIP 파일을 다운로드합니다.
  3. 파일 관리자에서 압축을 풉니다.
  4. 사물별 폴더를 ~/object_recognition_raspi_app/obj_data 안으로 이동합니다.
폴더 이름이 정답 이름입니다. 예: obj_data/keyboard, obj_data/mouse, obj_data/bottle
이미지셋 폴더 확인 명령어
cd ~/object_recognition_raspi_app
find obj_data -maxdepth 2 -type f | head -30

4단계. 공통 카메라 기능 파일 만들기

① nano로 Python 파일 만들기

camera_utils.py 만들기 명령어
cd ~/object_recognition_raspi_app
nano camera_utils.py

② 전체 코드 복사·붙여넣기

아래 코드 복사하기를 누른 뒤 nano 화면에서 Ctrl + Shift + V로 붙여넣습니다. 환경에 따라 마우스 오른쪽 버튼의 붙여넣기를 사용합니다.

camera_utils.py 전체 코드
# -*- coding: utf-8 -*-
import os
import shutil
import subprocess
import tempfile
import cv2

class CameraSource:
    def __init__(self, width=640, height=480):
        self.width = int(width)
        self.height = int(height)
        self.cap = None
        self.mode = None
        self.temp_path = os.path.join(tempfile.gettempdir(), 'raspi_object_frame.jpg')

        cap = cv2.VideoCapture(0)
        if cap.isOpened():
            cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
            cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
            ok, frame = cap.read()
            if ok and frame is not None:
                self.cap = cap
                self.mode = 'video'
                return
        cap.release()

        if shutil.which('raspistill'):
            self.mode = 'raspistill'
            return

        raise RuntimeError('No camera source was found. Check /dev/video0 or raspistill.')

    def read(self):
        if self.mode == 'video':
            return self.cap.read()

        command = [
            'raspistill', '-n', '-t', '180',
            '-w', str(self.width), '-h', str(self.height),
            '-q', '75', '-o', self.temp_path,
        ]
        try:
            subprocess.run(
                command, check=True, timeout=6,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
            return False, None

        frame = cv2.imread(self.temp_path)
        return frame is not None, frame

    def release(self):
        if self.cap is not None:
            self.cap.release()
        if os.path.exists(self.temp_path):
            try:
                os.remove(self.temp_path)
            except OSError:
                pass

    def mode_name(self):
        if self.mode == 'video':
            return 'OpenCV VideoCapture (/dev/video0)'
        return 'Legacy CSI camera (raspistill)'

③ 저장하고 nano 종료하기

1Ctrl + O 저장
2파일명을 확인하고 Enter
3Ctrl + X 종료

④ 실행하기

실행 명령어
cd ~/object_recognition_raspi_app
python3 -m py_compile camera_utils.py
USB 카메라의 /dev/video0을 우선 사용하고, 없으면 raspistill로 자동 전환합니다. 문법 검사에서 아무 메시지가 없으면 정상입니다.

5단계. 카메라 테스트 파일 만들기

① nano로 Python 파일 만들기

0_camera_test.py 만들기 명령어
cd ~/object_recognition_raspi_app
nano 0_camera_test.py

② 전체 코드 복사·붙여넣기

아래 코드 복사하기를 누른 뒤 nano 화면에서 Ctrl + Shift + V로 붙여넣습니다. 환경에 따라 마우스 오른쪽 버튼의 붙여넣기를 사용합니다.

0_camera_test.py 전체 코드
# -*- coding: utf-8 -*-
import cv2
from camera_utils import CameraSource

def main():
    camera = CameraSource(640, 480)
    print('Camera test started.')
    print('Camera mode:', camera.mode_name())
    print('Click the camera window and press q to stop.')
    try:
        while True:
            ok, frame = camera.read()
            if not ok or frame is None:
                print('A camera frame could not be captured.')
                break
            cv2.putText(frame, camera.mode_name(), (12, 28),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2)
            cv2.imshow('Raspberry Pi Camera Test', frame)
            if cv2.waitKey(1) & 0xFF == ord('q'):
                break
    finally:
        camera.release()
        cv2.destroyAllWindows()

if __name__ == '__main__':
    main()

③ 저장하고 nano 종료하기

1Ctrl + O 저장
2파일명을 확인하고 Enter
3Ctrl + X 종료

④ 실행하기

실행 명령어
cd ~/object_recognition_raspi_app
python3 0_camera_test.py
카메라 창이 열리면 정상입니다. 창을 클릭한 뒤 q를 누르면 종료됩니다.

라즈베리파이 속도 최적화

MobileNetV2 CNN은 인식률이 높은 대신 Raspberry Pi 4 CPU에서 매 프레임 실행하면 카메라 화면이 잠깐씩 멈출 수 있습니다. 이번 버전은 카메라 화면과 CNN 계산을 분리했습니다.

① 부드러운 화면카메라 프리뷰는 메인 루프에서 계속 표시합니다.
② 백그라운드 CNN무거운 CNN 판정은 별도 스레드에서 약 0.30초 간격으로 실행합니다.
③ ROI 부하 감소자동 사각형 탐색은 2프레임마다 실행하고 중간 프레임은 이전 위치를 재사용합니다.
학습시간: CNN 특징 추출은 최초 학습 때는 시간이 필요합니다. 대신 이번 버전은 학습 이미지가 전혀 바뀌지 않은 상태에서 1_train_object_model.py를 다시 실행하면 기존 CNN v3 모델을 즉시 재사용합니다.
속도를 더 높이고 싶다면: INFERENCE_INTERVAL = 0.30을 0.40~0.50으로 높이면 화면이 더 부드러워지고, 0.20~0.25로 낮추면 물체명 반응은 빨라지지만 Pi에서 다시 끊김이 생길 수 있습니다.

인식 알고리즘을 CNN으로 변경한 이유

이전 HSV·HOG·k-NN 방식에서는 키보드의 강한 선과 반복 무늬가 다른 사물보다 지나치게 강한 특징으로 작용해, 가방·스마트폰·모니터·마우스 등이 키보드로 오인식되는 현상이 나타날 수 있었습니다.

① MobileNetV2ImageNet으로 사전학습된 경량 CNN이 물체의 고수준 시각 특징을 추출합니다.
② 사용자 이미지셋 유지기존 obj_data의 각 학습 사진을 CNN 특징 벡터로 변환해 사용합니다.
③ 클래스 균형 비교각 클래스에서 가까운 CNN 특징을 비교하므로 클래스별 이미지 수 차이의 영향을 줄입니다.
최초 1회 인터넷 필요: 1_train_object_model.py를 처음 실행하면 약 14MB의 mobilenetv2-7.onnx를 ONNX Model Zoo에서 자동 다운로드합니다. 한 번 저장되면 다음 실행부터 다시 받지 않습니다.
기존 모델 재사용 금지: CNN v3는 이전 object_model.npz와 구조가 다릅니다. 반드시 6단계 학습부터 다시 실행해 새 모델을 만드세요.

6단계. 사물 인식 모델 학습 파일 만들기

중요 · CNN v3 모델 재학습 필요: 이번 버전은 MobileNetV2 CNN 특징을 사용하므로 이전 HOG·HSV·k-NN 모델과 구조가 다릅니다. 먼저 python3 1_train_object_model.py를 다시 실행해 model/object_model.npz를 새로 만든 뒤 실시간 인식과 인식률 측정을 실행하세요.

① nano로 Python 파일 만들기

1_train_object_model.py 만들기 명령어
cd ~/object_recognition_raspi_app
nano 1_train_object_model.py

② 전체 코드 복사·붙여넣기

아래 코드 복사하기를 누른 뒤 nano 화면에서 Ctrl + Shift + V로 붙여넣습니다. 환경에 따라 마우스 오른쪽 버튼의 붙여넣기를 사용합니다.

1_train_object_model.py 전체 코드
# -*- coding: utf-8 -*-
import os
import cv2
import numpy as np

DATA_DIR = 'obj_data'
MODEL_DIR = 'model'
MODEL_PATH = os.path.join(MODEL_DIR, 'object_model.npz')
REPORT_PATH = os.path.join(MODEL_DIR, 'training_report.txt')
IMAGE_EXTS = ('.jpg', '.jpeg', '.png', '.bmp', '.webp')

def build_data_signature(class_names):
    parts = []
    for class_name in class_names:
        class_dir = os.path.join(DATA_DIR, class_name)
        for file_name in sorted(os.listdir(class_dir)):
            if not file_name.lower().endswith(IMAGE_EXTS):
                continue
            path = os.path.join(class_dir, file_name)
            try:
                st = os.stat(path)
                parts.append('{}|{}|{}|{}'.format(
                    class_name, file_name, int(st.st_size), int(st.st_mtime)
                ))
            except OSError:
                pass
    return '\n'.join(parts)

def can_reuse_existing_model(signature):
    if not os.path.exists(MODEL_PATH):
        return False
    try:
        model = np.load(MODEL_PATH, allow_pickle=False)
        if 'model_version' not in model.files or int(model['model_version'][0]) != 3:
            return False
        if 'data_signature' not in model.files:
            return False
        return str(model['data_signature'][0]) == signature
    except Exception:
        return False



MOBILENET_PATH = os.path.join('model', 'mobilenetv2-7.onnx')
MOBILENET_URL = (
    'https://github.com/onnx/models/raw/refs/heads/main/'
    'validated/vision/classification/mobilenet/model/mobilenetv2-7.onnx'
)

def ensure_mobilenet_model():
    """MobileNetV2 ONNX 모델이 없으면 한 번만 다운로드합니다."""
    os.makedirs('model', exist_ok=True)

    if os.path.exists(MOBILENET_PATH) and os.path.getsize(MOBILENET_PATH) > 1000000:
        return

    print('MobileNetV2 model is not installed.')
    print('Downloading about 14 MB from the ONNX Model Zoo...')
    try:
        import urllib.request
        urllib.request.urlretrieve(MOBILENET_URL, MOBILENET_PATH)
    except Exception as e:
        raise RuntimeError(
            'MobileNetV2 download failed. Connect Raspberry Pi to the internet and try again. / '
            + str(e)
        )

    if (not os.path.exists(MOBILENET_PATH)
            or os.path.getsize(MOBILENET_PATH) < 1000000):
        raise RuntimeError(
            'Downloaded MobileNetV2 file is invalid. Delete model/mobilenetv2-7.onnx and try again.'
        )

def load_cnn():
    ensure_mobilenet_model()

    try:
        cv2.setNumThreads(max(1, min(4, os.cpu_count() or 1)))
    except Exception:
        pass

    try:
        net = cv2.dnn.readNetFromONNX(MOBILENET_PATH)
        net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
        net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
    except Exception as e:
        raise RuntimeError('OpenCV could not load MobileNetV2 ONNX model. / ' + str(e))
    return net

def prepare_cnn_input(image):
    """
    MobileNetV2 입력:
    RGB 224x224, 0~1 스케일 후 ImageNet mean/std 정규화.
    물체 비율은 유지하고 회색 여백을 넣습니다.
    """
    h, w = image.shape[:2]
    if h <= 0 or w <= 0:
        raise ValueError('Invalid image size')

    size = 224
    scale = min(float(size) / w, float(size) / h)
    nw = max(1, int(round(w * scale)))
    nh = max(1, int(round(h * scale)))
    resized = cv2.resize(image, (nw, nh), interpolation=cv2.INTER_AREA)

    canvas = np.full((size, size, 3), 127, dtype=np.uint8)
    x = (size - nw) // 2
    y = (size - nh) // 2
    canvas[y:y+nh, x:x+nw] = resized

    rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    rgb = (rgb - mean) / std

    blob = np.transpose(rgb, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
    return blob

def extract_cnn_features(image, net):
    """
    MobileNetV2의 1000차원 ImageNet 출력 벡터를
    사물의 고수준 CNN 특징으로 사용합니다.
    """
    blob = prepare_cnn_input(image)
    net.setInput(blob)
    output = net.forward().reshape(-1).astype(np.float32)

    # 크기/이동에 덜 민감한 비교를 위해 L2 정규화
    norm = float(np.linalg.norm(output))
    if norm > 0:
        output /= norm
    return output


def main():
    os.makedirs(MODEL_DIR, exist_ok=True)

    if not os.path.isdir(DATA_DIR):
        print('The obj_data folder does not exist.')
        return

    class_names = sorted([
        name for name in os.listdir(DATA_DIR)
        if os.path.isdir(os.path.join(DATA_DIR, name))
    ])

    if not class_names:
        print('No class folders were found in obj_data.')
        return

    data_signature = build_data_signature(class_names)

    if can_reuse_existing_model(data_signature):
        print('Training images have not changed.')
        print('Existing MobileNetV2 CNN v3 model can be reused.')
        print('Model file:', MODEL_PATH)
        return

    try:
        net = load_cnn()
    except Exception as e:
        print(e)
        return

    print('CNN feature extractor: MobileNetV2 ONNX')
    print('Training images are converted into CNN feature vectors.')

    names = []
    all_features = []
    all_labels = []
    counts = []

    for class_name in class_names:
        class_dir = os.path.join(DATA_DIR, class_name)
        class_features = []

        for file_name in sorted(os.listdir(class_dir)):
            if not file_name.lower().endswith(IMAGE_EXTS):
                continue

            image = cv2.imread(os.path.join(class_dir, file_name))
            if image is None:
                continue

            try:
                class_features.append(extract_cnn_features(image, net))
                count_now = len(class_features)
                if count_now == 1 or count_now % 10 == 0:
                    print('  {}: {} images processed'.format(class_name, count_now))
            except Exception as e:
                print('Skipped:', file_name, '/', e)

        if not class_features:
            print('Skipped empty class:', class_name)
            continue

        model_class_index = len(names)
        names.append(class_name)
        counts.append(len(class_features))

        for feature in class_features:
            all_features.append(feature)
            all_labels.append(model_class_index)

        print('{}: {} images'.format(class_name, len(class_features)))

    if not all_features:
        print('No valid images were found.')
        return

    features = np.stack(all_features).astype(np.float32)
    labels = np.array(all_labels, dtype=np.int32)

    np.savez_compressed(
        MODEL_PATH,
        model_version=np.array([3], dtype=np.int32),
        feature_type=np.array(['mobilenetv2_logits'], dtype='<U32'),
        data_signature=np.array([data_signature], dtype='<U200000'),
        names=np.array(names, dtype='<U64'),
        features=features,
        labels=labels,
        counts=np.array(counts, dtype=np.int32),
    )

    with open(REPORT_PATH, 'w', encoding='utf-8') as report:
        report.write('Object model training report - MobileNetV2 CNN v3\n')
        report.write('==============================================\n')
        for name, count in zip(names, counts):
            report.write('{}: {} images\n'.format(name, count))
        report.write('\nFeature extractor: MobileNetV2 ONNX / ImageNet pretrained\n')
        report.write('Classifier: class-balanced CNN nearest-neighbor scoring\n')

    print('Training completed.')
    print('Model version: 3 / MobileNetV2 CNN')
    print('Model file:', MODEL_PATH)
    print('Report file:', REPORT_PATH)

if __name__ == '__main__':
    main()

③ 저장하고 nano 종료하기

1Ctrl + O 저장
2파일명을 확인하고 Enter
3Ctrl + X 종료

④ 실행하기

실행 명령어
cd ~/object_recognition_raspi_app
python3 1_train_object_model.py
성공하면 model/object_model.npz와 model/training_report.txt가 생성됩니다.
scikit-learn과 joblib 없이 OpenCV와 NumPy만 사용합니다.

실시간 화면 표시와 인식 안정화

속도개선 버전에서 카메라 끊김은 해결되었지만, ROI 위쪽의 인식 결과와 상태 문구가 같은 위치에 겹칠 수 있었습니다. 이번 버전은 인식 결과를 화면 왼쪽 위의 반투명 정보 패널 하나에만 표시합니다.

표시 정리Object, CNN 유사도, gap을 고정 패널에 두어 ROI 선과 겹치지 않습니다.
반응속도 보완CNN 판정 간격을 0.30초에서 0.22초로 줄였습니다.
결과 안정화최근 3번의 CNN 판정을 짧게 투표하여 순간적인 오인식을 줄입니다.
중요: CNN 모델과 학습 데이터는 변경하지 않았습니다. 따라서 이번 수정 때문에 다시 학습할 필요는 없고, 2_live_object_recognition.py만 교체해 먼저 테스트할 수 있습니다.

볼펜처럼 얇고 긴 물체 ROI 개선

기존 자동 ROI는 일정 면적과 가로·세로 크기를 가진 물체를 중심으로 찾았기 때문에, 볼펜처럼 길이는 길지만 폭이 좁은 물체는 경계 후보에서 빠지기 쉬웠습니다.

① 얇은 물체 별도 탐색가로세로 비율이 큰 긴 물체는 작은 면적이어도 후보로 인정합니다.
② 방향별 여백볼펜의 긴 방향은 적게, 짧은 방향은 넉넉히 확장하여 CNN이 물체 전체를 보기 쉽게 합니다.
③ 최소 ROI 크기너무 가느다란 사각형이 CNN에 들어가지 않도록 최소 120×120 영역을 확보합니다.
사용 팁: 볼펜은 가능하면 화면 중앙에 놓되, 바닥과 색 대비가 나도록 두면 자동 사각형이 훨씬 안정적으로 잡힙니다. 이번 수정은 2_live_object_recognition.py와 3_measure_recognition_rate.py에 모두 적용됩니다.

SEARCHING에서 멈추는 오류 수정

얇은 물체 ROI 수정 과정에서 K_PER_CLASS, MIN_SIMILARITY, MIN_MARGIN 설정값이 7·8단계 코드에서 빠졌습니다. 이 때문에 CNN 백그라운드 스레드에서 NameError가 발생했지만, 기존 코드가 오류를 숨겨 화면에는 계속 SEARCHING만 표시되었습니다.

이번 수정: 세 설정값을 복구했고, 앞으로 CNN 오류가 생기면 터미널에 CNN inference error:로 출력하고 카메라 화면에도 ERROR와 간단한 오류 내용을 표시하도록 바꿨습니다.
재학습 불필요: 현재 MobileNetV2 CNN v3 모델은 그대로 사용할 수 있습니다. 2_live_object_recognition.py와 3_measure_recognition_rate.py만 교체하면 됩니다.

7단계 자동 ROI + 중앙 고정 ROI 절충 방식

7단계에서는 먼저 자동 ROI로 물체를 따라갑니다. 자동 ROI가 여러 프레임 연속으로 물체를 놓치면 8단계에서 안정적으로 사용한 중앙 고정 ROI로 자동 전환합니다.

AUTO물체가 안정적으로 검출될 때는 자동 사각형이 물체를 따라갑니다.
FIXED자동 검출이 연속으로 실패하면 중앙의 넓은 고정 사각형을 사용합니다.
자동 복귀고정 모드에서도 자동 탐색을 계속하고, 다시 안정적으로 검출되면 AUTO로 돌아갑니다.
화면 확인: 상단 정보 패널의 ROI AUTO 또는 ROI FIXED로 현재 방식을 확인할 수 있습니다. FIXED 모드에서는 노란색 중앙 사각형과 AUTO ROI lost - use center box 안내가 표시됩니다.
재학습 불필요: CNN 모델은 변경하지 않았습니다. 2_live_object_recognition.py만 교체하면 됩니다.

7단계. 실시간 사물 인식 파일 만들기

① nano로 Python 파일 만들기

2_live_object_recognition.py 만들기 명령어
cd ~/object_recognition_raspi_app
nano 2_live_object_recognition.py

② 전체 코드 복사·붙여넣기

아래 코드 복사하기를 누른 뒤 nano 화면에서 Ctrl + Shift + V로 붙여넣습니다. 환경에 따라 마우스 오른쪽 버튼의 붙여넣기를 사용합니다.

2_live_object_recognition.py 전체 코드
# -*- coding: utf-8 -*-
import os
import time
import threading
from collections import deque
import cv2
import numpy as np
from camera_utils import CameraSource

MODEL_PATH = os.path.join('model', 'object_model.npz')
INFERENCE_INTERVAL = 0.22
ROI_EVERY_N_FRAMES = 2
RESULT_HISTORY = 3
AUTO_ROI_FAIL_LIMIT = 8  # 연속 실패 프레임 수
AUTO_ROI_RECOVER_LIMIT = 5  # 자동 ROI 복귀에 필요한 연속 성공 수


MOBILENET_PATH = os.path.join('model', 'mobilenetv2-7.onnx')
MOBILENET_URL = (
    'https://github.com/onnx/models/raw/refs/heads/main/'
    'validated/vision/classification/mobilenet/model/mobilenetv2-7.onnx'
)

def ensure_mobilenet_model():
    """MobileNetV2 ONNX 모델이 없으면 한 번만 다운로드합니다."""
    os.makedirs('model', exist_ok=True)

    if os.path.exists(MOBILENET_PATH) and os.path.getsize(MOBILENET_PATH) > 1000000:
        return

    print('MobileNetV2 model is not installed.')
    print('Downloading about 14 MB from the ONNX Model Zoo...')
    try:
        import urllib.request
        urllib.request.urlretrieve(MOBILENET_URL, MOBILENET_PATH)
    except Exception as e:
        raise RuntimeError(
            'MobileNetV2 download failed. Connect Raspberry Pi to the internet and try again. / '
            + str(e)
        )

    if (not os.path.exists(MOBILENET_PATH)
            or os.path.getsize(MOBILENET_PATH) < 1000000):
        raise RuntimeError(
            'Downloaded MobileNetV2 file is invalid. Delete model/mobilenetv2-7.onnx and try again.'
        )

def load_cnn():
    ensure_mobilenet_model()

    try:
        cv2.setNumThreads(max(1, min(4, os.cpu_count() or 1)))
    except Exception:
        pass

    try:
        net = cv2.dnn.readNetFromONNX(MOBILENET_PATH)
        net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
        net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
    except Exception as e:
        raise RuntimeError('OpenCV could not load MobileNetV2 ONNX model. / ' + str(e))
    return net

def prepare_cnn_input(image):
    """
    MobileNetV2 입력:
    RGB 224x224, 0~1 스케일 후 ImageNet mean/std 정규화.
    물체 비율은 유지하고 회색 여백을 넣습니다.
    """
    h, w = image.shape[:2]
    if h <= 0 or w <= 0:
        raise ValueError('Invalid image size')

    size = 224
    scale = min(float(size) / w, float(size) / h)
    nw = max(1, int(round(w * scale)))
    nh = max(1, int(round(h * scale)))
    resized = cv2.resize(image, (nw, nh), interpolation=cv2.INTER_AREA)

    canvas = np.full((size, size, 3), 127, dtype=np.uint8)
    x = (size - nw) // 2
    y = (size - nh) // 2
    canvas[y:y+nh, x:x+nw] = resized

    rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    rgb = (rgb - mean) / std

    blob = np.transpose(rgb, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
    return blob

def extract_cnn_features(image, net):
    """
    MobileNetV2의 1000차원 ImageNet 출력 벡터를
    사물의 고수준 CNN 특징으로 사용합니다.
    """
    blob = prepare_cnn_input(image)
    net.setInput(blob)
    output = net.forward().reshape(-1).astype(np.float32)

    # 크기/이동에 덜 민감한 비교를 위해 L2 정규화
    norm = float(np.linalg.norm(output))
    if norm > 0:
        output /= norm
    return output


def detect_object_roi(frame, previous_roi=None):
    """
    일반 물체 + 얇고 긴 물체를 함께 찾는 자동 ROI.
    볼펜처럼 면적은 작지만 길이가 긴 물체는 별도 조건으로 허용합니다.
    """
    h, w = frame.shape[:2]
    frame_area = float(w * h)
    cx_frame = w / 2.0
    cy_frame = h / 2.0

    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    gray = cv2.GaussianBlur(gray, (5, 5), 0)

    # 얇은 물체의 양쪽 경계가 끊어지지 않도록 조금 더 강하게 연결
    edges = cv2.Canny(gray, 28, 95)
    close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
    edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, close_kernel, iterations=2)
    edges = cv2.dilate(
        edges,
        cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)),
        iterations=1
    )

    contours, _ = cv2.findContours(
        edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    candidates = []

    for contour in contours:
        contour_area = float(cv2.contourArea(contour))
        x, y, rw, rh = cv2.boundingRect(contour)

        if rw <= 0 or rh <= 0:
            continue

        box_area = float(rw * rh)
        area_ratio = box_area / frame_area
        contour_ratio = contour_area / frame_area

        long_side = float(max(rw, rh))
        short_side = float(max(1, min(rw, rh)))
        elongation = long_side / short_side

        # 일반 물체 조건
        general_ok = (
            area_ratio >= 0.012
            and area_ratio <= 0.78
            and rw >= 35
            and rh >= 35
        )

        # 볼펜·연필·리모컨처럼 얇고 긴 물체 조건
        thin_ok = (
            elongation >= 2.2
            and long_side >= 95
            and short_side >= 8
            and area_ratio >= 0.0020
            and area_ratio <= 0.28
            and contour_ratio >= 0.00045
        )

        if not (general_ok or thin_ok):
            continue

        obj_cx = x + rw / 2.0
        obj_cy = y + rh / 2.0

        dx = (obj_cx - cx_frame) / max(1.0, cx_frame)
        dy = (obj_cy - cy_frame) / max(1.0, cy_frame)
        center_distance = min(1.0, (dx * dx + dy * dy) ** 0.5)
        center_score = 1.0 - center_distance

        # 큰 일반 물체와, 충분히 긴 얇은 물체를 모두 살림
        area_score = min(1.0, area_ratio / 0.20)
        thin_bonus = min(0.30, (elongation - 2.2) * 0.04) if thin_ok else 0.0

        score = center_score * 0.62 + area_score * 0.28 + thin_bonus

        candidates.append(
            (score, x, y, rw, rh, thin_ok, elongation)
        )

    if candidates:
        candidates.sort(key=lambda item: item[0], reverse=True)
        _, x, y, rw, rh, is_thin, elongation = candidates[0]

        # 얇은 물체는 CNN이 형태를 보면서도 너무 배경만 보지 않도록
        # 긴 방향보다 짧은 방향에 여백을 더 줍니다.
        if is_thin:
            if rw >= rh:
                pad_x = int(rw * 0.12)
                pad_y = max(35, int(rh * 1.25))
            else:
                pad_x = max(35, int(rw * 1.25))
                pad_y = int(rh * 0.12)
        else:
            pad_x = int(rw * 0.15)
            pad_y = int(rh * 0.15)

        x1 = max(0, x - pad_x)
        y1 = max(0, y - pad_y)
        x2 = min(w, x + rw + pad_x)
        y2 = min(h, y + rh + pad_y)

        # CNN 입력이 지나치게 좁아지는 것을 방지하는 최소 ROI 크기
        min_roi_w = 120
        min_roi_h = 120
        roi_w = x2 - x1
        roi_h = y2 - y1

        if roi_w < min_roi_w:
            extra = (min_roi_w - roi_w) // 2 + 1
            x1 = max(0, x1 - extra)
            x2 = min(w, x2 + extra)

        if roi_h < min_roi_h:
            extra = (min_roi_h - roi_h) // 2 + 1
            y1 = max(0, y1 - extra)
            y2 = min(h, y2 + extra)

        detected_roi = (x1, y1, x2, y2)

        # 사각형이 심하게 튀지 않도록 이전 ROI와 부드럽게 연결
        if previous_roi is not None:
            alpha = 0.48
            px1, py1, px2, py2 = previous_roi
            detected_roi = (
                int(px1 * (1.0 - alpha) + x1 * alpha),
                int(py1 * (1.0 - alpha) + y1 * alpha),
                int(px2 * (1.0 - alpha) + x2 * alpha),
                int(py2 * (1.0 - alpha) + y2 * alpha),
            )

        return detected_roi, True

    # 잠깐 놓쳤으면 직전 ROI를 유지
    if previous_roi is not None:
        return previous_roi, False

    # 최초 탐색용 중앙 가이드 영역
    guide_w = int(w * 0.52)
    guide_h = int(h * 0.62)
    x1 = (w - guide_w) // 2
    y1 = (h - guide_h) // 2
    return (x1, y1, x1 + guide_w, y1 + guide_h), False


def get_fixed_fallback_roi(frame):
    """
    자동 ROI가 불안정할 때 사용하는 중앙 고정 ROI.
    8단계 측정 영역과 같은 비율을 사용합니다.
    """
    h, w = frame.shape[:2]
    roi_w = int(w * 0.62)
    roi_h = int(h * 0.62)
    x1 = (w - roi_w) // 2
    y1 = (h - roi_h) // 2
    return (x1, y1, x1 + roi_w, y1 + roi_h)

K_PER_CLASS = 3
MIN_SIMILARITY = 0.35
MIN_MARGIN = 0.008

def predict_object(image, names, train_features, train_labels, net):
    feature = extract_cnn_features(image, net)
    similarities = np.dot(train_features, feature)

    class_scores = []
    for class_index, class_name in enumerate(names):
        class_sims = similarities[train_labels == class_index]
        if class_sims.size == 0:
            class_scores.append((-1.0, str(class_name)))
            continue

        k = min(K_PER_CLASS, class_sims.size)
        nearest = np.partition(class_sims, class_sims.size - k)[-k:]
        score = float(np.mean(nearest))
        class_scores.append((score, str(class_name)))

    class_scores.sort(key=lambda item: item[0], reverse=True)
    best_score, best_name = class_scores[0]
    second_score = class_scores[1][0] if len(class_scores) > 1 else -1.0
    margin = best_score - second_score

    if best_score < MIN_SIMILARITY or margin < MIN_MARGIN:
        return 'UNKNOWN', best_score * 100.0, margin * 100.0

    return best_name, best_score * 100.0, margin * 100.0

def resolve_target_name(target, names):
    target_key = target.strip().lower()
    lookup = {str(name).strip().lower(): str(name) for name in names}

    if target_key in lookup:
        return lookup[target_key]

    # smartphone / smartphones 같은 단순 단수·복수 차이 자동 보정
    candidates = []
    if target_key.endswith('s'):
        candidates.append(target_key[:-1])
    else:
        candidates.append(target_key + 's')

    for candidate in candidates:
        if candidate in lookup:
            return lookup[candidate]
    return None


def load_model():
    if not os.path.exists(MODEL_PATH):
        raise RuntimeError('The object model was not found. Run 1_train_object_model.py first.')

    model = np.load(MODEL_PATH, allow_pickle=False)
    if 'features' not in model.files or 'labels' not in model.files:
        raise RuntimeError('Old object model detected. Run 1_train_object_model.py again.')

    if 'model_version' not in model.files or int(model['model_version'][0]) != 3:
        raise RuntimeError(
            'This is not the MobileNetV2 CNN v3 model. Run 1_train_object_model.py again.'
        )

    return model['names'], model['features'], model['labels']

def main():
    try:
        names, train_features, train_labels = load_model()
        net = load_cnn()
    except Exception as e:
        print(e)
        return

    camera = CameraSource(640, 480)
    previous_roi = None

    print('Live object recognition started.')
    print('Camera mode:', camera.mode_name())
    print('Model: MobileNetV2 CNN v3 - smooth preview mode')
    print('Camera preview runs continuously; CNN runs in background.')
    print('Put one object near the center. q: quit')

    # 공유 상태
    state_lock = threading.Lock()
    shared_roi_image = [None]
    shared_request_id = [0]
    last_processed_id = [0]
    stop_event = threading.Event()

    result = {
        'name': 'SEARCHING',
        'similarity': 0.0,
        'margin': 0.0,
        'error': ''
    }
    recent_results = deque(maxlen=RESULT_HISTORY)

    def inference_worker():
        """무거운 CNN 계산을 카메라 표시와 분리합니다."""
        while not stop_event.is_set():
            roi_image = None
            request_id = 0

            with state_lock:
                if shared_request_id[0] != last_processed_id[0]:
                    if shared_roi_image[0] is not None:
                        roi_image = shared_roi_image[0].copy()
                        request_id = shared_request_id[0]

            if roi_image is None:
                time.sleep(0.01)
                continue

            try:
                name, similarity, margin = predict_object(
                    roi_image, names, train_features, train_labels, net
                )
                with state_lock:
                    recent_results.append((name, similarity, margin))

                    vote_counts = {}
                    for item_name, item_similarity, item_margin in recent_results:
                        vote_counts[item_name] = vote_counts.get(item_name, 0) + 1

                    stable_name = max(vote_counts, key=vote_counts.get)
                    chosen = [
                        item for item in recent_results
                        if item[0] == stable_name
                    ]

                    result['name'] = stable_name
                    result['similarity'] = sum(item[1] for item in chosen) / float(len(chosen))
                    result['margin'] = sum(item[2] for item in chosen) / float(len(chosen))
                    last_processed_id[0] = request_id
            except Exception as e:
                with state_lock:
                    result['name'] = 'ERROR'
                    result['error'] = str(e)
                    last_processed_id[0] = request_id
                print('CNN inference error:', e)

    worker = threading.Thread(target=inference_worker, daemon=True)
    worker.start()

    frame_count = 0
    last_request_time = 0.0
    auto_fail_count = 0
    auto_success_count = 0
    use_fixed_fallback = False

    try:
        while True:
            ok, frame = camera.read()
            if not ok or frame is None:
                break

            frame_count += 1

            # 자동 ROI를 우선 사용하되, 연속 실패하면 중앙 고정 ROI로 전환합니다.
            # 고정 ROI 사용 중에도 자동 ROI 탐색은 계속하여 안정적으로 다시 잡히면 자동 복귀합니다.
            auto_box = previous_roi
            object_found = False

            if previous_roi is None or frame_count % ROI_EVERY_N_FRAMES == 0:
                auto_box, object_found = detect_object_roi(frame, previous_roi)

                if object_found:
                    auto_success_count += 1
                    auto_fail_count = 0
                    previous_roi = auto_box
                else:
                    auto_fail_count += 1
                    auto_success_count = 0
            else:
                # 중간 프레임에서는 직전 상태를 유지
                object_found = auto_fail_count == 0

            if not use_fixed_fallback and auto_fail_count >= AUTO_ROI_FAIL_LIMIT:
                use_fixed_fallback = True
                auto_success_count = 0

            if use_fixed_fallback and auto_success_count >= AUTO_ROI_RECOVER_LIMIT:
                use_fixed_fallback = False
                auto_fail_count = 0

            if use_fixed_fallback or previous_roi is None:
                x1, y1, x2, y2 = get_fixed_fallback_roi(frame)
                roi_mode = 'FIXED'
            else:
                x1, y1, x2, y2 = previous_roi
                roi_mode = 'AUTO'

            now = time.time()

            # CNN에는 최신 ROI만 전달. 이전 추론이 밀려도 큐가 쌓이지 않습니다.
            if now - last_request_time >= INFERENCE_INTERVAL:
                roi = frame[y1:y2, x1:x2]
                if roi.size > 0:
                    with state_lock:
                        shared_roi_image[0] = roi.copy()
                        shared_request_id[0] += 1
                    last_request_time = now

            with state_lock:
                name = result['name']
                similarity = result['similarity']
                margin = result['margin']
                error_text = result.get('error', '')

            roi_status = roi_mode
            if name == 'ERROR':
                box_color = (0, 0, 255)
            elif name in ('UNKNOWN', 'SEARCHING'):
                box_color = (0, 200, 255)
            else:
                box_color = (0, 255, 0)
            if roi_mode == 'FIXED':
                # 고정 fallback은 조금 더 굵게 표시
                cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 255), 3)
                cv2.putText(
                    frame,
                    'AUTO ROI lost - use center box',
                    (x1, min(frame.shape[0] - 12, y2 + 24)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0, 255, 255), 2
                )
            else:
                cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)

            # 글자가 ROI 선이나 다른 문구와 겹치지 않도록
            # 화면 왼쪽 위의 고정 정보 패널 하나에만 표시합니다.
            overlay = frame.copy()
            cv2.rectangle(overlay, (8, 8), (500, 105), (0, 0, 0), -1)
            cv2.addWeighted(overlay, 0.62, frame, 0.38, 0, frame)

            cv2.putText(
                frame,
                'Object: {}'.format(name),
                (18, 35),
                cv2.FONT_HERSHEY_SIMPLEX, 0.72, box_color, 2
            )
            cv2.putText(
                frame,
                'CNN {:.1f}%   gap {:.2f}%   ROI {}'.format(
                    similarity, margin, roi_status
                ),
                (18, 67),
                cv2.FONT_HERSHEY_SIMPLEX, 0.54, (255, 255, 255), 2
            )

            if error_text:
                cv2.putText(
                    frame,
                    ('ERR: ' + error_text)[:58],
                    (18, 94),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.42, (0, 0, 255), 1
                )

            cv2.imshow('Object Recognition - Raspberry Pi', frame)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                break

    finally:
        stop_event.set()
        worker.join(timeout=1.0)
        camera.release()
        cv2.destroyAllWindows()

if __name__ == '__main__':
    main()

③ 저장하고 nano 종료하기

1Ctrl + O 저장
2파일명을 확인하고 Enter
3Ctrl + X 종료
실행 전 권장: 먼저 python3 -m py_compile 2_live_object_recognition.py를 실행하세요. 아무 메시지가 없으면 들여쓰기와 문법이 정상입니다.

④ 실행하기

실행 명령어
cd ~/object_recognition_raspi_app
python3 2_live_object_recognition.py
사물을 중앙 사각형 안에 크게 보여줍니다. %는 확률이 아니라 유사도입니다.

8단계 인식률 측정은 중앙 고정 ROI 사용

실시간 체험인 7단계는 자동 ROI를 유지하지만, 8단계 인식률 측정은 모든 물체를 동일한 조건으로 비교하기 위해 중앙의 고정 사각형을 사용합니다.

① 자동 추적 제거볼펜처럼 얇은 물체도 ROI가 다른 곳으로 튀지 않습니다.
② 5초 준비시간각 횟수마다 물체 전체를 초록색 사각형 안에 넣습니다.
③ 동일 조건 측정가방·볼펜·마우스·스마트폰 등을 같은 크기와 위치의 영역에서 비교합니다.
권장 배치: 볼펜은 가능하면 사각형의 중앙에 가로 또는 세로로 놓고, 물체의 양 끝이 모두 사각형 안에 들어오게 하세요. 배경과 색 대비가 나면 더 안정적입니다.
재학습 불필요: 이번 수정은 8단계 측정 방식만 바꾸는 것이므로 기존 MobileNetV2 CNN v3 모델을 그대로 사용할 수 있습니다. 3_measure_recognition_rate.py만 교체하면 됩니다.

8단계. 정해진 횟수로 목표 사물 인식률 측정하기

측정 방식 개선: 각 측정 전에 5초 준비시간을 제공합니다. 이 시간 동안 물체를 사각형 안에 안정적으로 놓을 수 있습니다. 준비가 끝나면 2초 동안 여러 프레임을 수집하고 약 0.2초 간격으로 여러 번 판정한 뒤, 가장 많이 나온 결과를 해당 횟수의 최종 판정으로 사용합니다. 따라서 한 순간의 흔들림이나 초점 변화에 덜 민감합니다.
카메라 화면 표시 개선: 이번 수정본에서는 10회 인식률 측정 중에도 OpenCV 카메라 창이 계속 표시됩니다. 현재 측정 횟수, 목표 사물, 남은 준비 시간, 자동 ROI 사각형이 화면에 보이며, 각 횟수 판정 직후에는 예측 결과와 현재 인식률도 잠시 표시됩니다. 카메라 창에서 q를 누르면 측정을 중단할 수 있습니다.
측정 원리: 목표 사물 이름과 측정 횟수를 입력합니다. 각 회차마다 준비 시간이 끝난 순간의 화면을 한 번 인식하고, 예측 이름이 목표 이름과 같으면 정답으로 계산합니다.
실험 방법: 한 회차가 끝나면 사물을 카메라 밖으로 뺐다가 다음 회차에 다시 보여주세요. 같은 화면을 연속으로 측정하는 것보다 실제로 여러 번 제시한 결과에 가깝습니다.

인식률(%) = 정답 횟수 ÷ 실제 측정 횟수 × 100

① nano로 Python 파일 만들기

3_measure_recognition_rate.py 만들기 명령어
cd ~/object_recognition_raspi_app
nano 3_measure_recognition_rate.py

② 전체 코드 복사·붙여넣기

아래 코드 복사하기를 누른 뒤 nano 화면에서 Ctrl + Shift + V로 붙여넣습니다. 환경에 따라 마우스 오른쪽 버튼의 붙여넣기를 사용합니다.

3_measure_recognition_rate.py 전체 코드
# -*- coding: utf-8 -*-
import os
import time
import cv2
import numpy as np
from camera_utils import CameraSource

MODEL_PATH = os.path.join('model', 'object_model.npz')
ATTEMPTS = 10
PREPARE_SECONDS = 5.0
CAPTURE_SECONDS = 2.5
SAMPLE_INTERVAL = 0.50


MOBILENET_PATH = os.path.join('model', 'mobilenetv2-7.onnx')
MOBILENET_URL = (
    'https://github.com/onnx/models/raw/refs/heads/main/'
    'validated/vision/classification/mobilenet/model/mobilenetv2-7.onnx'
)

def ensure_mobilenet_model():
    """MobileNetV2 ONNX 모델이 없으면 한 번만 다운로드합니다."""
    os.makedirs('model', exist_ok=True)

    if os.path.exists(MOBILENET_PATH) and os.path.getsize(MOBILENET_PATH) > 1000000:
        return

    print('MobileNetV2 model is not installed.')
    print('Downloading about 14 MB from the ONNX Model Zoo...')
    try:
        import urllib.request
        urllib.request.urlretrieve(MOBILENET_URL, MOBILENET_PATH)
    except Exception as e:
        raise RuntimeError(
            'MobileNetV2 download failed. Connect Raspberry Pi to the internet and try again. / '
            + str(e)
        )

    if (not os.path.exists(MOBILENET_PATH)
            or os.path.getsize(MOBILENET_PATH) < 1000000):
        raise RuntimeError(
            'Downloaded MobileNetV2 file is invalid. Delete model/mobilenetv2-7.onnx and try again.'
        )

def load_cnn():
    ensure_mobilenet_model()

    try:
        cv2.setNumThreads(max(1, min(4, os.cpu_count() or 1)))
    except Exception:
        pass

    try:
        net = cv2.dnn.readNetFromONNX(MOBILENET_PATH)
        net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
        net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
    except Exception as e:
        raise RuntimeError('OpenCV could not load MobileNetV2 ONNX model. / ' + str(e))
    return net

def prepare_cnn_input(image):
    """
    MobileNetV2 입력:
    RGB 224x224, 0~1 스케일 후 ImageNet mean/std 정규화.
    물체 비율은 유지하고 회색 여백을 넣습니다.
    """
    h, w = image.shape[:2]
    if h <= 0 or w <= 0:
        raise ValueError('Invalid image size')

    size = 224
    scale = min(float(size) / w, float(size) / h)
    nw = max(1, int(round(w * scale)))
    nh = max(1, int(round(h * scale)))
    resized = cv2.resize(image, (nw, nh), interpolation=cv2.INTER_AREA)

    canvas = np.full((size, size, 3), 127, dtype=np.uint8)
    x = (size - nw) // 2
    y = (size - nh) // 2
    canvas[y:y+nh, x:x+nw] = resized

    rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    rgb = (rgb - mean) / std

    blob = np.transpose(rgb, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
    return blob

def extract_cnn_features(image, net):
    """
    MobileNetV2의 1000차원 ImageNet 출력 벡터를
    사물의 고수준 CNN 특징으로 사용합니다.
    """
    blob = prepare_cnn_input(image)
    net.setInput(blob)
    output = net.forward().reshape(-1).astype(np.float32)

    # 크기/이동에 덜 민감한 비교를 위해 L2 정규화
    norm = float(np.linalg.norm(output))
    if norm > 0:
        output /= norm
    return output


def get_fixed_measurement_roi(frame):
    """
    8단계 인식률 측정 전용 중앙 고정 ROI.
    자동으로 물체를 찾아다니지 않고, 모든 물체를 동일한 조건에서 측정합니다.
    """
    h, w = frame.shape[:2]

    # 화면 중앙의 넉넉한 고정 측정영역
    roi_w = int(w * 0.62)
    roi_h = int(h * 0.62)

    x1 = (w - roi_w) // 2
    y1 = (h - roi_h) // 2
    x2 = x1 + roi_w
    y2 = y1 + roi_h

    return x1, y1, x2, y2


K_PER_CLASS = 3
MIN_SIMILARITY = 0.35
MIN_MARGIN = 0.008

def predict_object(image, names, train_features, train_labels, net):
    feature = extract_cnn_features(image, net)
    similarities = np.dot(train_features, feature)

    class_scores = []
    for class_index, class_name in enumerate(names):
        class_sims = similarities[train_labels == class_index]
        if class_sims.size == 0:
            class_scores.append((-1.0, str(class_name)))
            continue

        k = min(K_PER_CLASS, class_sims.size)
        nearest = np.partition(class_sims, class_sims.size - k)[-k:]
        score = float(np.mean(nearest))
        class_scores.append((score, str(class_name)))

    class_scores.sort(key=lambda item: item[0], reverse=True)
    best_score, best_name = class_scores[0]
    second_score = class_scores[1][0] if len(class_scores) > 1 else -1.0
    margin = best_score - second_score

    if best_score < MIN_SIMILARITY or margin < MIN_MARGIN:
        return 'UNKNOWN', best_score * 100.0, margin * 100.0

    return best_name, best_score * 100.0, margin * 100.0

def resolve_target_name(target, names):
    target_key = target.strip().lower()
    lookup = {str(name).strip().lower(): str(name) for name in names}

    if target_key in lookup:
        return lookup[target_key]

    # smartphone / smartphones 같은 단순 단수·복수 차이 자동 보정
    candidates = []
    if target_key.endswith('s'):
        candidates.append(target_key[:-1])
    else:
        candidates.append(target_key + 's')

    for candidate in candidates:
        if candidate in lookup:
            return lookup[candidate]
    return None


def load_model():
    if not os.path.exists(MODEL_PATH):
        raise RuntimeError('The object model was not found. Run 1_train_object_model.py first.')

    model = np.load(MODEL_PATH, allow_pickle=False)

    if 'features' not in model.files or 'labels' not in model.files:
        raise RuntimeError('Old object model detected. Run 1_train_object_model.py again.')

    if 'model_version' not in model.files or int(model['model_version'][0]) != 3:
        raise RuntimeError(
            'This is not the MobileNetV2 CNN v3 model. Run 1_train_object_model.py again.'
        )

    return model['names'], model['features'], model['labels']

def main():
    try:
        names, train_features, train_labels = load_model()
        net = load_cnn()
    except Exception as e:
        print(e)
        return

    print('Trained object names:')
    for name in names:
        print('-', name)

    target_input = input('Enter the target object name: ').strip()
    target = resolve_target_name(target_input, names)

    if target is None:
        print('Target name does not match a trained class.')
        return

    if target.lower() != target_input.lower():
        print("Target name auto-corrected: '{}' -> '{}'".format(target_input, target))

    camera = CameraSource(640, 480)
    correct = 0

    print('')
    print('Recognition rate test: {} attempts'.format(ATTEMPTS))
    print('Model: MobileNetV2 CNN v3')
    print('Measurement ROI: fixed center box')
    print('Put the ENTIRE object inside the green box before each attempt.')
    print('Target:', target)

    try:
        for attempt in range(1, ATTEMPTS + 1):
            print('')
            print('Prepare attempt {}/{}.'.format(attempt, ATTEMPTS))
            print('You have {:.0f} seconds to place the object.'.format(PREPARE_SECONDS))

            # 1) 준비시간: 물체 위치/거리/사각형 확인
            prepare_start = time.time()
            latest_frame = None

            while time.time() - prepare_start < PREPARE_SECONDS:
                ok, frame = camera.read()
                if not ok or frame is None:
                    continue

                latest_frame = frame
                x1, y1, x2, y2 = get_fixed_measurement_roi(frame)

                preview = frame.copy()
                box_color = (0, 255, 0)
                cv2.rectangle(preview, (x1, y1), (x2, y2), box_color, 3)

                remaining = max(0.0, PREPARE_SECONDS - (time.time() - prepare_start))
                cv2.putText(
                    preview,
                    'Prepare {}/{}  Target: {}'.format(attempt, ATTEMPTS, target),
                    (15, 28),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.58, (255, 255, 255), 2
                )
                cv2.putText(
                    preview,
                    'Place ENTIRE object inside box  {:.1f}s'.format(remaining),
                    (15, 58),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.58, (255, 255, 0), 2
                )

                cv2.imshow('Recognition Rate Test - Raspberry Pi', preview)

                key = cv2.waitKey(1) & 0xFF
                if key == ord('q'):
                    print('Measurement stopped by user.')
                    return

            # 2) 여러 프레임 CNN 판정
            print('Capturing for {:.1f} seconds...'.format(CAPTURE_SECONDS))
            capture_start = time.time()
            predictions = []
            last_sample_time = 0.0

            while time.time() - capture_start < CAPTURE_SECONDS:
                ok, frame = camera.read()
                if not ok or frame is None:
                    continue

                latest_frame = frame
                x1, y1, x2, y2 = get_fixed_measurement_roi(frame)

                now = time.time()
                if now - last_sample_time >= SAMPLE_INTERVAL:
                    roi = frame[y1:y2, x1:x2]
                    if roi.size > 0:
                        pred_name, pred_similarity, pred_margin = predict_object(
                            roi, names, train_features, train_labels, net
                        )
                        predictions.append(
                            (pred_name, pred_similarity, pred_margin)
                        )
                    last_sample_time = now

                preview = frame.copy()
                cv2.rectangle(preview, (x1, y1), (x2, y2), (255, 200, 0), 2)
                remaining = max(0.0, CAPTURE_SECONDS - (time.time() - capture_start))
                cv2.putText(
                    preview,
                    'CNN measuring... {:.1f}s'.format(remaining),
                    (15, 28),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.62, (255, 255, 0), 2
                )
                cv2.imshow('Recognition Rate Test - Raspberry Pi', preview)

                key = cv2.waitKey(1) & 0xFF
                if key == ord('q'):
                    print('Measurement stopped by user.')
                    return

            if not predictions:
                print('{}: no valid camera samples / wrong'.format(attempt))
                continue

            vote_counts = {}
            for pred_name, pred_similarity, pred_margin in predictions:
                vote_counts[pred_name] = vote_counts.get(pred_name, 0) + 1

            name = max(vote_counts, key=vote_counts.get)
            chosen = [p for p in predictions if p[0] == name]
            similarity = sum(p[1] for p in chosen) / float(len(chosen))
            margin = sum(p[2] for p in chosen) / float(len(chosen))
            votes = vote_counts[name]
            total_votes = len(predictions)

            is_correct = name.lower() == target.lower()
            if is_correct:
                correct += 1

            print(
                '{}: prediction={} / cnn={:.1f}% / gap={:.2f}% / votes={}/{} / {}'.format(
                    attempt, name, similarity, margin, votes, total_votes,
                    'correct' if is_correct else 'wrong'
                )
            )

            rate = correct / float(attempt) * 100.0
            print('Current rate: {:.2f}% ({}/{})'.format(rate, correct, attempt))

            if latest_frame is not None:
                x1, y1, x2, y2 = get_fixed_measurement_roi(latest_frame)
                result_frame = latest_frame.copy()
                result_color = (0, 255, 0) if is_correct else (0, 0, 255)
                cv2.rectangle(result_frame, (x1, y1), (x2, y2), result_color, 2)
                cv2.putText(
                    result_frame,
                    'Prediction: {} CNN {:.1f}% votes {}/{}'.format(
                        name, similarity, votes, total_votes
                    ),
                    (15, 30),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.55, result_color, 2
                )
                cv2.putText(
                    result_frame,
                    'Rate: {:.1f}% ({}/{})'.format(rate, correct, attempt),
                    (15, 60),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.58, (255, 255, 0), 2
                )
                cv2.imshow('Recognition Rate Test - Raspberry Pi', result_frame)
                cv2.waitKey(800)

    finally:
        camera.release()
        cv2.destroyAllWindows()

    final_rate = correct / float(ATTEMPTS) * 100.0

    print('')
    print('========== Final result ==========')
    print('Target:', target)
    print('Correct / total: {}/{}'.format(correct, ATTEMPTS))
    print('Recognition rate: {:.2f}%'.format(final_rate))

    with open('recognition_result.txt', 'w', encoding='utf-8') as f:
        f.write('Target: {}\n'.format(target))
        f.write('Correct / total: {}/{}\n'.format(correct, ATTEMPTS))
        f.write('Recognition rate: {:.2f}%\n'.format(final_rate))
        f.write('Model: MobileNetV2 CNN v3\n')
        f.write('Measurement ROI: fixed center box\n')

    print('Result file: recognition_result.txt')

if __name__ == '__main__':
    main()

③ 저장하고 nano 종료하기

1Ctrl + O 저장
2파일명을 확인하고 Enter
3Ctrl + X 종료

④ 문법 검사와 실행

인식률 측정 실행 명령어
cd ~/object_recognition_raspi_app
python3 -m py_compile 3_measure_recognition_rate.py
python3 3_measure_recognition_rate.py
예를 들어 목표 이름에 keyboard, 측정 횟수에 10을 입력하면 키보드를 10번 제시하여 맞힌 횟수와 최종 인식률을 자동 계산합니다.
중간에 멈추려면 카메라 창을 클릭하고 ESC를 누릅니다. 이 경우 실제로 완료된 횟수를 기준으로 인식률을 계산합니다.

⑤ 결과 파일 확인

최종 결과와 회차별 예측값은 recognition_result.txt에 저장됩니다.

결과 확인 명령어
cd ~/object_recognition_raspi_app
cat recognition_result.txt
========== Final result ==========
Target: keyboard
Correct / total: 8/10
Recognition rate: 80.00%
Result file: recognition_result.txt

물체 크기에 따라 자동으로 변하는 인식 사각형

고정 중앙 사각형 대신 OpenCV 윤곽선 검출로 중앙 부근의 물체를 찾고 크기에 맞춰 ROI를 자동 조절합니다.

동작: 윤곽 검출 → 작은 잡음 제거 → 중앙 물체 우선 → 약 15% 여백 → 사각형 떨림 완화

물체를 가까이 가져오면 사각형이 커지고 멀리하면 작아집니다.

잘 안 될 때

증상확인 및 해결
No such file or directorycd ~/object_recognition_raspi_app 후 ls -la로 파일이 저장되었는지 확인합니다.
supported=1 detected=0전원을 끈 뒤 CSI 카메라 리본 케이블 방향과 연결 상태를 확인합니다.
카메라 창이 열리지 않음raspistill -n -t 1000 -o test.jpg로 사진 촬영을 먼저 시험합니다.
화면이 느림raspistill 방식은 USB 웹캠보다 느릴 수 있습니다.
이미지셋을 찾지 못함obj_data/keyboard/사진.jpg처럼 사물별 폴더 안에 이미지가 있어야 합니다.
인식 결과가 부정확함배경·거리·각도·밝기가 다른 이미지를 더 준비한 후 모델을 다시 학습합니다.
mkdir 파일명.py를 실행함파일이 아니라 폴더가 생성됩니다. rmdir 파일명.py로 삭제하고 nano 파일명.py를 실행합니다.

수업 종료 후 전체 삭제

실습 폴더 삭제 명령어
cd ~
rm -rf ~/object_recognition_raspi_app
ls
삭제 후 이미지셋과 모델을 복구하기 어렵습니다. 선생님 안내에 따라 실행합니다.

체크리스트