PiDiNetエッジ検出(ソースコードと実行結果)

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 opencv-python numpy

PiDiNetエッジ検出プログラム

概要

このプログラムは、動画や画像からエッジを検出する。

主要技術

参考文献

[1] Su, Z., Liu, W., Yu, Z., Hu, D., Liao, Q., Tian, Q., Pietikainen, M., & Liu, L. (2021). Pixel Difference Networks for Efficient Edge Detection. Proceedings of IEEE International Conference on Computer Vision (ICCV). arXiv:2108.07009


# PiDiNetエッジ検出プログラム
# 特徴技術名: PiDiNet (Pixel Difference Network) - 従来のエッジ検出器の知識を活用した軽量で効率的なディープラーニングベースのエッジ検出モデル
# 出典: Su, Z., Liu, W., Yu, Z., Hu, D., Liao, Q., Tian, Q., Pietikainen, M., & Liu, L. (2021). Pixel Difference Networks for Efficient Edge Detection. Proceedings of IEEE International Conference on Computer Vision (ICCV). arXiv:2108.07009
# 特徴機能: Pixel Difference Convolution (PDC)を用いた高品質なエッジマップ生成 - 深層学習の表現力と従来手法の効率性を融合し、100FPSの高速処理と1M未満のパラメータで動作。人間の認識能力(ODS F-score 0.803)を上回る0.807を達成
# 学習済みモデル: table5_pidinet.pth - BSDS500データセットでODS F-score 0.807を達成。複雑な環境下でも高精度にエッジを検出可能。GitHubから自動的にダウンロードされる
# 方式設計
#   - 関連利用技術:
#     - PyTorch: ディープラーニングフレームワーク、モデルの実行と推論に使用
#     - OpenCV: 画像・動画処理ライブラリ、入力の取得と結果の表示に使用
#     - NumPy: 数値計算ライブラリ、配列操作と画像処理に使用
#     - tkinter: Pythonの標準GUIライブラリ、ファイル選択ダイアログに使用
#   - 入力と出力: 入力: 動画(ユーザは「0:動画ファイル,1:カメラ,2:サンプル動画」のメニューで選択.0:動画ファイルの場合はtkinterでファイル選択.1の場合はOpenCVでカメラが開く.2の場合はhttps://github.com/opencv/opencv/raw/master/samples/data/vtest.aviを使用)
#     出力: OpenCV画面でエッジマップを重ね合わせた画像をリアルタイム表示。画面内にエッジ密度などの処理結果をテキストで表示。1秒間隔でprint()による処理結果の表示。処理結果はプログラム終了時にresult.txtに保存
#   - 処理手順:
#     1. 入力方法の選択と入力ソースの初期化
#     2. 動画フレームの取得
#     3. BGRからRGBへの変換と画像の正規化
#     4. PiDiNetモデルによるエッジマップの生成
#     5. エッジマップのカラー変換と元画像への重ね合わせ(適応的なブレンディングを使用)
#     6. 処理結果の表示と記録
#     7. 結果の保存
#   - 前処理、後処理:
#     - 前処理: 入力画像をBGRからRGBに変換し、PyTorchテンソルに変換。0-255から0-1の範囲に正規化し、ImageNet統計で標準化。バッチ次元の追加
#     - 後処理: エッジマップをグレースケールからJETカラーマップに変換。エッジの強さに応じた適応的なアルファブレンディングで元画像と重ね合わせて視覚化
#   - 追加処理:
#     - エッジ密度の計算: エッジマップの平均値からエッジの密度を計算し、画像上に表示
#     - 時間情報の追加: 現在時刻を画像上に表示して処理時間の参考情報を提供
#     - 適応的なブレンディング: エッジの強さに応じて元画像との重ね合わせ比率を変化させ、視認性を向上
#   - 調整を必要とする設定値:
#     - alpha: 元画像の重み (0.7) - 重ね合わせ時の元画像の透明度を決定
#     - beta: エッジマップの重み (0.6) - 重ね合わせ時のエッジマップの透明度を決定
# 将来方策: alpha、betaの値を調整可能にするためのスライダーUIの実装 - ユーザーがリアルタイムに値を調整しながら最適な視覚化効果を得られるようにする
# その他の重要事項:
#   - PiDiNetはRGB (カラー画像)を入力として使用するエッジ検出モデルです
#   - エッジマップは二値化せず、グラデーションを保持したままカラーマップ変換を行います
#   - GPU環境がある場合は自動的にGPUを使用して処理速度が向上します
#   - 適応的なブレンディングにより、エッジの強い部分をより明確に表示します
# 前準備:
# pip install -U torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
# pip install opencv-python numpy

import cv2
import tkinter as tk
from tkinter import filedialog
import urllib.request
import os
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import time

# 設定パラメータ
ALPHA = 0.7  # 元画像の重み(値が大きいほど元画像が濃く表示される)
BETA = 0.6   # エッジマップの重み(値が大きいほどエッジが濃く表示される)
COLORMAP = cv2.COLORMAP_JET  # エッジマップの色付け方法(cv2.COLORMAPから選択)
FONT_SIZE = 0.7  # テキスト表示のフォントサイズ
FONT_COLOR = (0, 255, 0)  # テキスト表示の色(BGR形式)
FONT_THICKNESS = 2  # テキスト表示の太さ
USE_ADAPTIVE_BLENDING = True  # 適応的なアルファブレンディングを使用するか

# ファイルとURL
SAMPLE_VIDEO_URL = 'https://github.com/opencv/opencv/raw/master/samples/data/vtest.avi?raw=true'
RESULT_FILE = 'result.txt'  # 結果を保存するファイル名
MODEL_URL = 'https://github.com/hellozhuo/pidinet/raw/master/trained_models/table5_pidinet.pth'
MODEL_PATH = 'pidinet_model.pth'

# 結果を保存するリスト
results = []

# PiDiNetの基本ブロック(修正版:正しい形状に対応)
class PiDiBlock(nn.Module):
    """PiDiNetの基本ブロック(エラー解析結果に基づく修正版)"""
    def __init__(self, in_channels, out_channels, stride=1):
        super(PiDiBlock, self).__init__()

        # 修正:conv1の形状は (in_channels, 1, 3, 3) - 入力チャンネル数に応じて変化
        self.conv1 = nn.Conv2d(in_channels, in_channels, kernel_size=3,
                              stride=stride, padding=1, bias=False, groups=in_channels)

        # conv2: Pointwise畳み込み (in_channels, out_channels, 1, 1)
        self.conv2 = nn.Conv2d(in_channels, out_channels, kernel_size=1,
                              stride=1, padding=0, bias=False)

        # ショートカット接続(必要な場合のみ)
        self.shortcut = None
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1,
                                    stride=stride, padding=0, bias=True)

    def forward(self, x):
        residual = x

        # Depthwise畳み込み
        out = self.conv1(x)

        # Pointwise畳み込み
        out = self.conv2(out)

        if self.shortcut is not None:
            residual = self.shortcut(x)

        out += residual
        return out

class AttentionModule(nn.Module):
    """アテンション機構(解析結果に基づく)"""
    def __init__(self, in_channels=24):
        super(AttentionModule, self).__init__()

        # 解析結果: conv1 (24, 4, 1, 1) with bias
        self.conv1 = nn.Conv2d(in_channels, 4, kernel_size=1, bias=True)

        # 解析結果: conv2 (1, 4, 3, 3) without bias
        self.conv2 = nn.Conv2d(4, 1, kernel_size=3, padding=1, bias=False)

    def forward(self, x):
        # チャンネル注意
        att = self.conv1(x)

        # 空間注意
        att = self.conv2(att)

        # シグモイド活性化
        att = torch.sigmoid(att)

        return x * att

class DilationModule(nn.Module):
    """拡張畳み込みモジュール(解析結果に基づく)"""
    def __init__(self, in_channels):
        super(DilationModule, self).__init__()

        # 解析結果: conv1 with bias
        self.conv1 = nn.Conv2d(in_channels, 24, kernel_size=1, bias=True)

        # 解析結果: 4つの異なる拡張率の畳み込み (24, 24, 3, 3) without bias
        self.conv2_1 = nn.Conv2d(24, 24, kernel_size=3, padding=1,
                                dilation=1, bias=False)
        self.conv2_2 = nn.Conv2d(24, 24, kernel_size=3, padding=2,
                                dilation=2, bias=False)
        self.conv2_3 = nn.Conv2d(24, 24, kernel_size=3, padding=3,
                                dilation=3, bias=False)
        self.conv2_4 = nn.Conv2d(24, 24, kernel_size=3, padding=4,
                                dilation=4, bias=False)

    def forward(self, x):
        x = self.conv1(x)

        # 4つの拡張畳み込みを並列実行
        out1 = self.conv2_1(x)
        out2 = self.conv2_2(x)
        out3 = self.conv2_3(x)
        out4 = self.conv2_4(x)

        # 加算で統合
        return out1 + out2 + out3 + out4

class ConvReduceModule(nn.Module):
    """チャンネル削減モジュール(解析結果に基づく)"""
    def __init__(self):
        super(ConvReduceModule, self).__init__()
        # 解析結果: conv (24, 1, 1, 1) with bias
        self.conv = nn.Conv2d(24, 1, kernel_size=1, bias=True)

    def forward(self, x):
        return self.conv(x)

class OfficialPiDiNet(nn.Module):
    """公式PiDiNet構造(形状エラー修正版)"""
    def __init__(self):
        super(OfficialPiDiNet, self).__init__()

        # moduleラッパーを作成(公式重みの構造に合わせる)
        self.module = nn.Module()

        # 解析結果: init_block (60, 3, 3, 3) without bias
        self.module.init_block = nn.Conv2d(3, 60, kernel_size=3, padding=1, bias=False)

        # Block1 (60ch維持) - 解析結果に基づく
        self.module.block1_1 = PiDiBlock(60, 60)
        self.module.block1_2 = PiDiBlock(60, 60)
        self.module.block1_3 = PiDiBlock(60, 60)

        # Block2 (60ch → 120ch) - ショートカット付き
        self.module.block2_1 = PiDiBlock(60, 120, stride=2)
        self.module.block2_2 = PiDiBlock(120, 120)
        self.module.block2_3 = PiDiBlock(120, 120)
        self.module.block2_4 = PiDiBlock(120, 120)

        # Block3 (120ch → 240ch) - ショートカット付き
        self.module.block3_1 = PiDiBlock(120, 240, stride=2)
        self.module.block3_2 = PiDiBlock(240, 240)
        self.module.block3_3 = PiDiBlock(240, 240)
        self.module.block3_4 = PiDiBlock(240, 240)

        # Block4 (240ch維持) - ショートカット付き
        self.module.block4_1 = PiDiBlock(240, 240, stride=2)
        self.module.block4_2 = PiDiBlock(240, 240)
        self.module.block4_3 = PiDiBlock(240, 240)
        self.module.block4_4 = PiDiBlock(240, 240)

        # マルチスケール拡張畳み込み(解析結果)
        self.module.dilations = nn.ModuleList([
            DilationModule(60),   # Block1からの特徴
            DilationModule(120),  # Block2からの特徴
            DilationModule(240),  # Block3からの特徴
            DilationModule(240),  # Block4からの特徴
        ])

        # アテンション機構(解析結果)
        self.module.attentions = nn.ModuleList([
            AttentionModule(24) for _ in range(4)
        ])

        # チャンネル削減(解析結果: conv_reduces.X.conv構造)
        self.module.conv_reduces = nn.ModuleList([
            ConvReduceModule() for _ in range(4)
        ])

        # 最終分類器(解析結果: 4→1, with bias)
        self.module.classifier = nn.Conv2d(4, 1, kernel_size=1, bias=True)

    def forward(self, x):
        # 初期特徴抽出
        x = self.module.init_block(x)

        # Block1
        x1 = self.module.block1_1(x)
        x1 = self.module.block1_2(x1)
        x1 = self.module.block1_3(x1)

        # Block2
        x2 = self.module.block2_1(x1)
        x2 = self.module.block2_2(x2)
        x2 = self.module.block2_3(x2)
        x2 = self.module.block2_4(x2)

        # Block3
        x3 = self.module.block3_1(x2)
        x3 = self.module.block3_2(x3)
        x3 = self.module.block3_3(x3)
        x3 = self.module.block3_4(x3)

        # Block4
        x4 = self.module.block4_1(x3)
        x4 = self.module.block4_2(x4)
        x4 = self.module.block4_3(x4)
        x4 = self.module.block4_4(x4)

        # マルチスケール特徴処理
        features = [x1, x2, x3, x4]
        edge_outputs = []

        for i, (feature, dilation, attention, conv_reduce) in enumerate(
            zip(features, self.module.dilations, self.module.attentions, self.module.conv_reduces)):

            # 拡張畳み込み
            dilated = dilation(feature)

            # アテンション適用
            attended = attention(dilated)

            # チャンネル削減
            edge = conv_reduce(attended)

            # 解像度統一(最大解像度にアップサンプリング)
            if i > 0:
                edge = F.interpolate(edge, size=edge_outputs[0].shape[2:],
                                   mode='bilinear', align_corners=False)

            edge_outputs.append(edge)

        # 4つのスケールを統合
        fused = torch.cat(edge_outputs, dim=1)  # [B, 4, H, W]

        # 最終分類
        final_output = self.module.classifier(fused)

        # 動画処理用に複数出力を返す(互換性維持)
        return final_output, edge_outputs[0], edge_outputs[1], edge_outputs[2], edge_outputs[3]

def load_official_weights(model, weight_path='pidinet_model.pth'):
    """公式重みの読み込み(strict=True対応)"""
    try:
        # state_dictの読み込み
        checkpoint = torch.load(weight_path, map_location='cpu')
        official_state_dict = checkpoint['state_dict']

        # モデルに重みを読み込み(解析結果に基づく完全一致)
        model.load_state_dict(official_state_dict, strict=True)
        print("✓ 公式重みの読み込みが完了しました(strict=True)")

        return model
    except Exception as e:
        print(f"重みの読み込みに失敗しました: {e}")
        print("フォールバック処理を実行します...")

        try:
            # フォールバック:部分読み込み
            model_dict = model.state_dict()
            compatible_dict = {}

            for key, value in official_state_dict.items():
                if key in model_dict and model_dict[key].shape == value.shape:
                    compatible_dict[key] = value

            model_dict.update(compatible_dict)
            model.load_state_dict(model_dict)
            print(f"✓ 部分的な重みの読み込みが完了しました ({len(compatible_dict)}/{len(model_dict)} パラメータ)")

        except Exception as e2:
            print(f"フォールバック処理も失敗しました: {e2}")
            print("モデルをランダム初期化で使用します")

        return model

def download_model():
    model_path = 'pidinet_model.pth'

    if not os.path.exists(model_path):
        print('PiDiNet学習済みモデルをダウンロード中...')
        try:
            urllib.request.urlretrieve('https://github.com/hellozhuo/pidinet/raw/master/trained_models/table5_pidinet.pth', model_path)
            print('ダウンロード完了')
        except Exception as e:
            print(f'モデルのダウンロードに失敗しました: {e}')
            exit()

    return model_path

def load_pidinet_model(model_path):
    """PiDiNetモデルの読み込み"""
    try:
        print("公式PiDiNetモデルを読み込み中...")
        model = OfficialPiDiNet()
        model = load_official_weights(model, model_path)
        model.eval()
        print("✓ モデルの読み込みが完了しました")
        return model
    except Exception as e:
        print(f'PiDiNetモデルの読み込みに失敗しました: {e}')
        exit()

def initialize_model():
    """
    PiDiNetモデルを初期化
    """
    print('PiDiNetモデルを初期化中...')
    try:
        model_path = download_model()
        model = load_pidinet_model(model_path)

        if torch.cuda.is_available():
            model = model.cuda()
            print('GPUを使用します')
        else:
            print('CPUを使用します')

        print('PiDiNetモデルの初期化が完了しました')
        return model
    except Exception as e:
        print(f'モデルの初期化に失敗しました: {e}')
        exit()

def preprocess_image(img):
    """
    画像を前処理してモデル入力用のテンソルに変換
    PiDiNetはRGB形式の入力を想定しているため、BGR→RGB変換を行う
    """
    # BGRからRGBに変換(PiDiNetはRGB形式の入力を想定)
    img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    # リサイズ
    img_resized = cv2.resize(img_rgb, (512, 512))

    # テンソルに変換して正規化(0-255から0-1の範囲に)
    img_tensor = torch.from_numpy(img_resized.transpose(2, 0, 1)).float() / 255.0

    # ImageNet統計で標準化
    mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
    std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
    img_tensor = (img_tensor - mean) / std

    img_tensor = img_tensor.unsqueeze(0)  # バッチ次元を追加

    # GPUが利用可能ならGPUに転送
    if torch.cuda.is_available():
        img_tensor = img_tensor.cuda()

    return img_tensor

def video_processing(frame):
    """
    PiDiNetモデルで動画フレームのエッジを検出し、視覚化
    エッジの強さに応じた適応的なブレンディングを使用して視認性を向上
    """
    # フレームが空の場合はそのまま返す
    if frame is None:
        return frame

    # 画像の前処理
    img_tensor = preprocess_image(frame)

    # モデルによるエッジ検出
    with torch.no_grad():  # 勾配計算を無効化して推論
        outputs = model(img_tensor)

        # 最終結果(fused output)を使用
        edge_map = torch.sigmoid(outputs[0]).squeeze().cpu().numpy()

        # GPUメモリの解放
        del outputs
        del img_tensor
        if torch.cuda.is_available():
            torch.cuda.empty_cache()

    # サイズを元のフレームに合わせる
    edge_map = cv2.resize(edge_map, (frame.shape[1], frame.shape[0]))

    # グレースケールをカラーマップに変換
    edge_colored = cv2.applyColorMap((edge_map * 255).astype(np.uint8), COLORMAP)

    if USE_ADAPTIVE_BLENDING:
        # エッジの強さに応じた適応的なアルファブレンディング
        # エッジが強い部分ほど不透明に表示
        edge_strength = edge_map.copy()
        edge_strength_normalized = cv2.normalize(edge_strength, None, 0, 1, cv2.NORM_MINMAX)
        alpha_mask = ALPHA + (1.0 - ALPHA) * edge_strength_normalized
        alpha_mask = np.expand_dims(alpha_mask, axis=2)
        alpha_mask = np.repeat(alpha_mask, 3, axis=2)  # 3チャンネル(BGR)に拡張

        # エッジマップを元の画像に重ね合わせ
        # numpy配列の直接演算を使用して適応的なブレンディングを実現
        result_frame = (frame * alpha_mask + edge_colored * BETA * (1 - alpha_mask)).astype(np.uint8)
    else:
        # 従来の固定重みによるブレンディング
        result_frame = cv2.addWeighted(frame, ALPHA, edge_colored, BETA, 0)

    # 処理結果の情報を取得
    edge_percentage = np.mean(edge_map) * 100
    result_info = f'エッジ密度: {edge_percentage:.2f}%'

    # 結果を記録
    results.append(result_info)

    # 処理結果の情報を画像に追加
    cv2.putText(result_frame, result_info, (10, 30),
                cv2.FONT_HERSHEY_SIMPLEX, FONT_SIZE, FONT_COLOR, FONT_THICKNESS)

    # 現在時刻を追加
    timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
    cv2.putText(result_frame, timestamp, (10, 60),
                cv2.FONT_HERSHEY_SIMPLEX, FONT_SIZE, FONT_COLOR, FONT_THICKNESS)

    # モデル名を表示
    model_info = 'モデル: PiDiNet'
    cv2.putText(result_frame, model_info, (10, 90),
                cv2.FONT_HERSHEY_SIMPLEX, FONT_SIZE, FONT_COLOR, FONT_THICKNESS)

    # 1秒に1回、処理結果を表示
    current_time = time.time()
    if not hasattr(video_processing, 'last_print_time') or current_time - video_processing.last_print_time >= 1.0:
        print(result_info)
        video_processing.last_print_time = current_time

    return result_frame

# プログラムの概要と操作方法を表示
print('PiDiNetエッジ検出プログラムを開始します')
print('このプログラムはPiDiNetモデルを使用して動画からエッジマップを生成します')
print('エッジマップは二値化せず、グラデーションを保持したまま表示されます')
print('表示される処理結果画像は、元画像とエッジマップを重ね合わせたものです')
print('qキーで終了します')

# PiDiNetモデルを初期化
model = initialize_model()

print('0: 動画ファイル')
print('1: カメラ')
print('2: サンプル動画')

choice = input('選択: ')
temp_file = None

if choice == '0':
    root = tk.Tk()
    root.withdraw()
    path = filedialog.askopenfilename()
    if not path:
        exit()
    cap = cv2.VideoCapture(path)
    if not cap.isOpened():
        print(f'動画ファイルの読み込みに失敗しました: {path}')
        exit()
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)  # バッファサイズを最小に設定
    if not cap.isOpened():
        print('カメラの初期化に失敗しました。カメラが接続されているか確認してください。')
        exit()
elif choice == '2':
    # サンプル動画ダウンロード・処理
    filename = 'vtest.avi'
    try:
        urllib.request.urlretrieve(SAMPLE_VIDEO_URL, filename)
        temp_file = filename
        cap = cv2.VideoCapture(filename)
        if not cap.isOpened():
            print(f'動画ファイルの読み込みに失敗しました: {filename}')
            exit()
    except Exception as e:
        print(f'動画のダウンロードに失敗しました: {SAMPLE_VIDEO_URL}')
        print(f'エラー: {e}')
        exit()
else:
    print('無効な選択です')
    exit()

# メイン処理
try:
    while True:
        cap.grab()
        ret, frame = cap.retrieve()
        if not ret:
            break

        processed_frame = video_processing(frame)
        cv2.imshow('Video', processed_frame)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
finally:
    cap.release()
    cv2.destroyAllWindows()
    if temp_file:
        os.remove(temp_file)

# 結果をファイルに保存
if results:
    with open(RESULT_FILE, 'w', encoding='utf-8') as f:
        for result in results:
            f.write(f'{result}\n')
    print(f'処理結果を{RESULT_FILE}に保存しました')