YOLOv10による物体検出(COCO 80クラス)(ソースコードと説明と利用ガイド)

【概要】YOLOv10は、物体検出アルゴリズムである。従来のYOLOシリーズが使用していたNon-Maximum Suppressionを不要とし、エンドツーエンド設計により後処理なしで物体検出を実現する。本プログラムは、動画やウェブカメラ映像から人、車、動物、家具などCOCO 80クラスの物体をリアルタイムで検出し、矩形とラベルで可視化する。CLAHE前処理によるコントラスト強化、6種類のモデルサイズ選択。日本語表示対応、検出結果の自動保存機能を備える。

プログラム利用ガイド

1. このプログラムの利用シーン

動画ファイルやウェブカメラの映像から、リアルタイムで80種類の物体を検出・可視化するためのソフトウェアである。監視システム、交通管理、製造業の品質管理、研究開発など、物体検出が必要な分野で使用される。

2. 主な機能

3. 基本的な使い方

  1. モデル選択:

    起動時にモデルサイズ(n/s/m/b/l/x)を選択する。処理速度を重視する場合はnano、精度を重視する場合はextra largeを選択する。

  2. 入力ソースの選択:

    0(動画ファイル)、1(カメラ)、2(サンプル動画)のいずれかを入力する。

  3. 検出処理の実行:

    選択した入力ソースから映像が表示され、検出された物体に矩形とラベルが描画される。

  4. 終了方法:

    映像表示ウィンドウでqキーを押すとプログラムが終了する。

4. 便利な機能

Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


pip install -U torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
pip install ultralytics opencv-python numpy pillow

YOLOv10による物体検出プログラム(COCO 80クラス)

概要

このプログラムは、YOLOv10を使用してリアルタイムでCOCO 80クラスの物体検出を行う。動画またはカメラ映像から物体を検出し、矩形とラベルで可視化する機能を提供する。

主要技術

YOLOv10

Wang et al.が2024年に開発した物体検出アルゴリズム[1]。Non-Maximum Suppressionを不要とするエンドツーエンド設計により、後処理なしで物体検出を実現する。

CLAHE (Contrast Limited Adaptive Histogram Equalization)

局所的なコントラスト強化技術[2]。YUV色空間の輝度チャンネルに適用することで、様々な照明条件下での検出精度を向上させる。

技術的特徴

実装の特色

動画のリアルタイム処理に対応し、以下の機能を備える:

参考文献

[1] Wang, A., Chen, H., Liu, L., Chen, K., Lin, Z., Han, J., & Ding, G. (2024). YOLOv10: Real-Time End-to-End Object Detection. arXiv:2405.14458. https://arxiv.org/abs/2405.14458

[2] Zuiderveld, K. (1994). Contrast Limited Adaptive Histogram Equalization. In Graphics Gems IV (pp. 474-485). Academic Press.

ソースコード


# プログラム名: YOLOv10による物体検出プログラム(COCO 80クラス)
# 特徴技術名: YOLOv10
# 出典: Wang, A., Chen, H., Liu, L., Chen, K., Lin, Z., Han, J., & Ding, G. (2024). YOLOv10: Real-Time End-to-End Object Detection. arXiv:2405.14458.
# 特徴機能: NMSフリー検出機能。Non-Maximum Suppressionを不要とする設計により、後処理なしで物体検出を実現。本プログラムではCOCO 80クラス全体を検出
# 学習済みモデル: YOLOv10 COCO事前学習済みモデル(80クラス全体)
#   モデルサイズ選択可能(デフォルト: n):
#   n (nano): yolov10n.pt - 最小
#   s (small): yolov10s.pt - 小
#   m (medium): yolov10m.pt - 中
#   b (balanced): yolov10b.pt - 中上
#   l (large): yolov10l.pt - 大
#   x (extra large): yolov10x.pt - 最大
# 方式設計:
#   - 関連利用技術:
#     - PyTorch: 深層学習フレームワーク、CUDA対応
#     - OpenCV: 画像処理、カメラ制御、描画処理、動画入出力管理
#   - 入力と出力:
#     入力: 動画(0:動画ファイル,1:カメラ,2:サンプル動画)
#     出力: 各フレームごとに全検出一覧をprint表示し、終了時にresult.txtに保存
#   - 処理手順: 1.フレーム取得、2.CLAHE前処理、3.YOLOv10推論、4.COCO 80クラス検出、5.信頼度閾値で選別、6.バウンディングボックス描画
#   - 前処理/後処理: 前処理はCLAHE(YUV色空間)とYOLO内部処理(640x640リサイズ、正規化)。後処理は信頼度閾値によるフィルタリング
#   - 追加処理: デバイス自動選択(GPU/CPU)、日本語クラス名表示、カメラモードではスレッド化フレーム取得
#   - 調整可能値: CONF_THRESH(検出信頼度閾値、デフォルト0.5)
# 環境: Windows想定(PillowでMeiryoフォントを使用)
# 特徴技術および学習済モデルの利用制限: YOLOv10はAGPL-3.0ライセンスで提供されています。商用利用の場合はUltralyticsのライセンスを確認してください。必ず利用者自身で利用制限を確認すること。
# 前準備:
#   - pip install -U torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
#   - pip install ultralytics opencv-python numpy pillow

import os
import time
import urllib.request
import tkinter as tk
from tkinter import filedialog
from datetime import datetime
import sys
import io
import threading

import cv2
import numpy as np
import torch
from ultralytics import YOLO
from PIL import Image, ImageDraw, ImageFont

# Windows文字エンコーディング設定
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)

# GPU/CPU自動選択
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'デバイス: {str(device)}')

# GPU使用時の最適化
if device.type == 'cuda':
    torch.backends.cudnn.benchmark = True

# モデル情報の構造化
MODEL_INFO = {
    'n': {'name': 'Nano', 'desc': '最小'},
    's': {'name': 'Small', 'desc': '小'},
    'm': {'name': 'Medium', 'desc': '中'},
    'b': {'name': 'Balanced', 'desc': '中上'},
    'l': {'name': 'Large', 'desc': '大'},
    'x': {'name': 'Extra Large', 'desc': '最大'}
}

# 調整可能な設定値
CONF_THRESH = 0.5           # 信頼度閾値 - 検出感度制御
IMG_SIZE = 640             # 推論画像サイズ
CLAHE_CLIP_LIMIT = 2.0     # CLAHE制限値
CLAHE_TILE_SIZE = (8, 8)   # CLAHEタイルサイズ
WINDOW_NAME = "YOLOv10物体検出"  # OpenCVウィンドウ名

# CLAHEオブジェクトをグローバルスコープで一度だけ定義(AIモデルの入力用にCLAHEを適用)
clahe = cv2.createCLAHE(clipLimit=CLAHE_CLIP_LIMIT, tileGridSize=CLAHE_TILE_SIZE)

# BGR→RGB色変換のヘルパー関数
def bgr_to_rgb(color_bgr):
    """BGRカラーをRGBカラーに変換"""
    return (int(color_bgr[2]), int(color_bgr[1]), int(color_bgr[0]))

# クラス色生成(HSV→BGR)
def generate_class_colors(num_classes):
    colors = []
    for i in range(num_classes):
        hue = int(180.0 * i / max(1, num_classes))
        hsv = np.uint8([[[hue, 255, 255]]])
        bgr = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)[0][0]
        colors.append((int(bgr[0]), int(bgr[1]), int(bgr[2])))
    return colors

# 80クラス分の色を生成
CLASS_COLORS = generate_class_colors(80)

# 日本語クラス名マッピング
CLASS_NAMES_JP = {
    'person': '人', 'bicycle': '自転車', 'car': '車', 'motorcycle': 'バイク',
    'airplane': '飛行機', 'bus': 'バス', 'train': '電車', 'truck': 'トラック',
    'boat': 'ボート', 'traffic light': '信号機', 'fire hydrant': '消火栓',
    'stop sign': '停止標識', 'parking meter': 'パーキングメーター', 'bench': 'ベンチ',
    'bird': '鳥', 'cat': '猫', 'dog': '犬', 'horse': '馬', 'sheep': '羊',
    'cow': '牛', 'elephant': '象', 'bear': '熊', 'zebra': 'シマウマ', 'giraffe': 'キリン',
    'backpack': 'リュック', 'umbrella': '傘', 'handbag': 'ハンドバッグ', 'tie': 'ネクタイ',
    'suitcase': 'スーツケース', 'frisbee': 'フリスビー', 'skis': 'スキー板',
    'snowboard': 'スノーボード', 'sports ball': 'ボール', 'kite': '凧',
    'baseball bat': 'バット', 'baseball glove': 'グローブ', 'skateboard': 'スケートボード',
    'surfboard': 'サーフボード', 'tennis racket': 'テニスラケット', 'bottle': 'ボトル',
    'wine glass': 'ワイングラス', 'cup': 'カップ', 'fork': 'フォーク', 'knife': 'ナイフ',
    'spoon': 'スプーン', 'bowl': 'ボウル', 'banana': 'バナナ', 'apple': 'リンゴ',
    'sandwich': 'サンドイッチ', 'orange': 'オレンジ', 'broccoli': 'ブロッコリー',
    'carrot': 'ニンジン', 'hot dog': 'ホットドッグ', 'pizza': 'ピザ', 'donut': 'ドーナツ',
    'cake': 'ケーキ', 'chair': '椅子', 'couch': 'ソファ', 'potted plant': '鉢植え',
    'bed': 'ベッド', 'dining table': 'テーブル', 'toilet': 'トイレ', 'tv': 'テレビ',
    'laptop': 'ノートPC', 'mouse': 'マウス', 'remote': 'リモコン', 'keyboard': 'キーボード',
    'cell phone': '携帯電話', 'microwave': '電子レンジ', 'oven': 'オーブン',
    'toaster': 'トースター', 'sink': 'シンク', 'refrigerator': '冷蔵庫',
    'book': '本', 'clock': '時計', 'vase': '花瓶', 'scissors': 'ハサミ',
    'teddy bear': 'ぬいぐるみ', 'hair drier': 'ドライヤー', 'toothbrush': '歯ブラシ'
}

# 日本語フォント設定
FONT_PATH = 'C:/Windows/Fonts/meiryo.ttc'
FONT_SIZE_MAIN = 20
FONT_SIZE_SMALL = 14
font_main = ImageFont.truetype(FONT_PATH, FONT_SIZE_MAIN)
font_small = ImageFont.truetype(FONT_PATH, FONT_SIZE_SMALL)

# グローバル変数
frame_count = 0
results_log = []
class_counts = {}
model = None
CLASS_NAMES = None

class ThreadedVideoCapture:
    """スレッド化されたVideoCapture(常に最新フレームを取得)"""
    def __init__(self, src, is_camera=False):
        if is_camera:
            self.cap = cv2.VideoCapture(src, cv2.CAP_DSHOW)
            fourcc = cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')
            self.cap.set(cv2.CAP_PROP_FOURCC, fourcc)
            self.cap.set(cv2.CAP_PROP_FPS, 60)
        else:
            self.cap = cv2.VideoCapture(src)

        self.grabbed, self.frame = self.cap.read()
        self.stopped = False
        self.lock = threading.Lock()
        self.thread = threading.Thread(target=self.update, args=())
        self.thread.daemon = True
        self.thread.start()

    def update(self):
        """バックグラウンドでフレームを取得し続ける"""
        while not self.stopped:
            grabbed, frame = self.cap.read()
            with self.lock:
                self.grabbed = grabbed
                if grabbed:
                    self.frame = frame

    def read(self):
        """最新フレームを返す"""
        with self.lock:
            return self.grabbed, self.frame.copy() if self.grabbed else None

    def isOpened(self):
        return self.cap.isOpened()

    def get(self, prop):
        return self.cap.get(prop)

    def release(self):
        self.stopped = True
        self.thread.join()
        self.cap.release()

def display_program_header():
    print('=== YOLOv10物体検出プログラム ===')
    print('概要: フレームごとに物体を検出し、矩形とラベルを描画して表示します')
    print('機能: YOLOv10によるCOCO 80クラス検出(NMSフリー設計)')
    print('技術: CLAHE(コントラスト強化)')
    print('操作: qキーで終了')
    print('出力: 各フレームの全検出一覧をprint表示し、終了時にresult.txtに保存')
    print('注意: YOLOv10モデルファイル(yolov10n.pt等)が必要です')
    print()

# ===== 物体検出タスク固有の処理 =====
def draw_detection_results(frame, detections):
    """物体検出の描画処理"""
    # バウンディングボックスを描画(OpenCVで)
    for det in detections:
        color = CLASS_COLORS[det['class'] % len(CLASS_COLORS)]
        cv2.rectangle(frame, (det['x1'], det['y1']),
                     (det['x2'], det['y2']), color, 2)

    # 構造化されたテキスト描画を実行(日本語クラス名で表示)
    texts_to_draw = []
    for det in detections:
        color = CLASS_COLORS[det['class'] % len(CLASS_COLORS)]
        jp_name = CLASS_NAMES_JP.get(det['name'], det['name'])
        texts_to_draw.append({
            'text': jp_name,
            'org': (det['x1'], max(0, det['y1'] - 22)),
            'color': bgr_to_rgb(color),
            'font_type': 'main'
        })
        texts_to_draw.append({
            'text': f"Conf:{det['conf']:.2f}",
            'org': (det['x1'], det['y2'] + 4),
            'color': (255, 255, 255),
            'font_type': 'small'
        })

    texts_to_draw.append({
        'text': f'YOLOv10 ({device.type}) | Frame: {frame_count} | Dets: {len(detections)}',
        'org': (10, 10),
        'color': (255, 255, 255),
        'font_type': 'main'
    })
    texts_to_draw.append({
        'text': '操作: q=終了',
        'org': (10, 36),
        'color': (255, 255, 0),
        'font_type': 'small'
    })

    frame = draw_texts_with_pillow(frame, texts_to_draw)
    return frame

def format_detection_output(detections):
    """物体検出の出力フォーマット(英語クラス名で出力)"""
    parts = [f'count={len(detections)}']
    for det in detections:
        x1, y1, x2, y2 = det['x1'], det['y1'], det['x2'], det['y2']
        name = det['name']
        conf = det['conf']
        parts.append(f'class={name},conf={conf:.3f},box=[{x1},{y1},{x2},{y2}]')
    return '; '.join(parts) if len(parts) == 1 else parts[0] + '; ' + ' | '.join(parts[1:])

# ===== 共通処理関数 =====
def draw_texts_with_pillow(bgr_frame, texts):
    """Pillowを使用した日本語テキスト描画, texts: list of dict with keys {text, org, color, font_type}"""
    img_pil = Image.fromarray(cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)

    for item in texts:
        text = item['text']
        x, y = item['org']
        color = item['color']  # RGB
        font_type = item.get('font_type', 'main')
        font = font_main if font_type == 'main' else font_small
        draw.text((x, y), text, font=font, fill=color)

    return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

def detect_objects(frame):
    """共通の検出処理(CLAHE前処理、YOLOv10推論、検出を実行)"""
    global model

    # AIモデルの入力用にCLAHEを適用(YUV色空間で輝度チャンネルのみ処理)
    yuv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV)
    yuv_frame[:, :, 0] = clahe.apply(yuv_frame[:, :, 0])
    enh_frame = cv2.cvtColor(yuv_frame, cv2.COLOR_YUV2BGR)

    # YOLOv10推論処理(NMSフリーなのでiouパラメータは不要)
    results = model.predict(enh_frame, conf=CONF_THRESH, imgsz=IMG_SIZE, verbose=False, device=str(device))
    curr_dets = []

    res = results[0]
    if res.boxes is not None:
        boxes = res.boxes.xyxy.cpu().numpy()
        confs = res.boxes.conf.cpu().numpy()
        clses = res.boxes.cls.cpu().numpy()

        for i in range(len(boxes)):
            box = boxes[i].astype(int)
            conf = float(confs[i])
            cls = int(clses[i])
            name = CLASS_NAMES.get(cls, str(cls))
            curr_dets.append({
                'x1': box[0], 'y1': box[1],
                'x2': box[2], 'y2': box[3],
                'conf': conf,
                'class': cls,
                'name': name
            })

    return curr_dets

def process_video_frame(frame, timestamp_ms, is_camera):
    """動画フレーム処理用ラッパー"""
    # 共通の検出処理
    detections = detect_objects(frame)

    # クラスごとの検出数を更新
    global class_counts
    for det in detections:
        name = det['name']
        if name not in class_counts:
            class_counts[name] = 0
        class_counts[name] += 1

    # 物体検出固有の描画処理
    frame = draw_detection_results(frame, detections)

    # 物体検出固有の出力フォーマット
    result = format_detection_output(detections)

    return frame, result

def video_frame_processing(frame, timestamp_ms, is_camera):
    """動画フレーム処理(標準形式)"""
    global frame_count
    current_time = time.time()
    frame_count += 1

    processed_frame, result = process_video_frame(frame, timestamp_ms, is_camera)
    return processed_frame, result, current_time

# プログラムヘッダー表示
display_program_header()

# モデル選択(対話的実装)
print("=== モデル選択 ===")
print('使用するYOLOv10モデルを選択してください:')
for key, info in MODEL_INFO.items():
    print(f'{key}: {info["name"]} - {info["desc"]}')
print()

model_choice = ''
while model_choice not in MODEL_INFO.keys():
    model_choice = input("選択 (n/s/m/b/l/x) [デフォルト: n]: ").strip().lower()
    if model_choice == '':
        model_choice = 'n'
        print('デフォルト(n)を使用します')
        break
    if model_choice not in MODEL_INFO.keys():
        print("無効な選択です。もう一度入力してください。")

MODEL_NAME = f'yolov10{model_choice}.pt'
print(f'選択されたモデル: {MODEL_NAME}')

# モデルの初期化
print(f"\nモデル初期化中...")
try:
    model = YOLO(MODEL_NAME)
    model.to(device)
    model.eval()
    CLASS_NAMES = model.names  # id -> name のマッピング取得
    print('モデル初期化が完了しました')
except Exception as e:
    print('モデルの初期化に失敗しました')
    print(f'エラー: {e}')
    print('注意: モデルファイルの存在とパスを確認してください')
    raise SystemExit(1)

# 入力選択
print("\n=== 入力ソース選択 ===")
print("0: 動画ファイル")
print("1: カメラ")
print("2: サンプル動画")

choice = input("選択: ")

is_camera = (choice == '1')

if choice == '0':
    root = tk.Tk()
    root.withdraw()
    path = filedialog.askopenfilename()
    if not path:
        raise SystemExit(1)
    cap = cv2.VideoCapture(path)
elif choice == '1':
    cap = ThreadedVideoCapture(0, is_camera=True)
else:
    print('サンプル動画をダウンロード中...')
    SAMPLE_URL = 'https://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.avi'
    SAMPLE_FILE = 'vtest.avi'
    urllib.request.urlretrieve(SAMPLE_URL, SAMPLE_FILE)
    cap = cv2.VideoCapture(SAMPLE_FILE)

if not cap.isOpened():
    print('動画ファイル・カメラを開けませんでした')
    raise SystemExit(1)

# フレームレートの取得とタイムスタンプ増分の計算
if is_camera:
    actual_fps = cap.get(cv2.CAP_PROP_FPS)
    print(f'カメラのfps: {actual_fps}')
    timestamp_increment = int(1000 / actual_fps) if actual_fps > 0 else 33
else:
    video_fps = cap.get(cv2.CAP_PROP_FPS)
    timestamp_increment = int(1000 / video_fps) if video_fps > 0 else 33

# メイン処理
print('\n=== 動画処理開始 ===')
print('操作方法:')
print('  q キー: プログラム終了')

start_time = time.time()
last_info_time = start_time
info_interval = 10.0  # 10秒ごとに表示
timestamp_ms = 0
total_processing_time = 0.0

try:
    while True:
        ret, frame = cap.read()
        if not ret:
            break

        timestamp_ms += timestamp_increment

        processing_start = time.time()
        processed_frame, result, current_time = video_frame_processing(frame, timestamp_ms, is_camera)
        processing_time = time.time() - processing_start
        total_processing_time += processing_time
        cv2.imshow(WINDOW_NAME, processed_frame)

        if result:
            if is_camera:
                timestamp = datetime.fromtimestamp(current_time).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                print(f'{timestamp}, {result}')
            else:
                print(f'Frame {frame_count}: {result}')

            results_log.append(result)

        # 情報提供(カメラモードのみ、info_interval秒ごと)
        if is_camera:
            elapsed = current_time - last_info_time
            if elapsed >= info_interval:
                total_elapsed = current_time - start_time
                actual_fps = frame_count / total_elapsed if total_elapsed > 0 else 0
                avg_processing_time = (total_processing_time / frame_count * 1000) if frame_count > 0 else 0
                print(f'[情報] 経過時間: {total_elapsed:.1f}秒, 処理フレーム数: {frame_count}, 実測fps: {actual_fps:.1f}, 平均処理時間: {avg_processing_time:.1f}ms')
                last_info_time = current_time

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

finally:
    print('\n=== プログラム終了 ===')
    cap.release()
    cv2.destroyAllWindows()

    if results_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            f.write('=== YOLOv10物体検出結果 ===\n')
            f.write(f'処理フレーム数: {frame_count}\n')
            f.write(f'使用モデル: {MODEL_NAME}\n')
            f.write(f'モデル情報: {MODEL_INFO[model_choice]["name"]} - {MODEL_INFO[model_choice]["desc"]}\n')
            f.write(f'使用デバイス: {str(device).upper()}\n')
            if device.type == 'cuda':
                f.write(f'GPU: {torch.cuda.get_device_name(0)}\n')
            f.write(f'画像処理: CLAHE適用(YUV色空間)\n')
            f.write(f'信頼度閾値: {CONF_THRESH}\n')
            if is_camera:
                f.write('形式: タイムスタンプ, 検出結果\n')
            else:
                f.write('形式: フレーム番号, 検出結果\n')
            f.write(f'\n検出されたクラス一覧:\n')
            for class_name, count in sorted(class_counts.items()):
                jp_name = CLASS_NAMES_JP.get(class_name, class_name)
                f.write(f'  {jp_name} ({class_name}): {count}回\n')
            f.write('\n')
            f.write('\n'.join(results_log))
        print(f'\n処理結果をresult.txtに保存しました')
        print(f'検出されたクラス数: {len(class_counts)}')