Real-ESRGANによる超解像(ソースコードと実行結果)


画質改善前

画質改善後

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 basicsr opencv-python pillow numpy requests scikit-image

Real-ESRGAN超解像プログラム

このプログラムは、Real-ESRGAN技術を用いて動画の各フレームに対して超解像度化処理を実行する。動画入力、カメラ入力、サンプル動画に対応し、リアルタイム表示と品質評価指標(PSNR・SSIM)の計算を行う。処理結果は連番PNG形式で保存され、FFmpegを用いて音声付きMP4動画として出力される。

主要技術

Real-ESRGAN (Real-World Enhanced Super-Resolution Generative Adversarial Network)

Real-ESRGANは、超解像度技術である[1]。高次劣化モデリングプロセス(High-order degradation modeling)により、ノイズ、ぼけ、JPEG圧縮アーティファクト等の複雑な劣化を考慮した超解像度化を実現する[1][2]。

Residual-in-Residual Dense Block (RRDB)

RRDBは、ESRGANで導入されたニューラルネットワーク構造である[3]。多階層残差ネットワークと密結合を組み合わせ、Batch Normalizationを除去した構造を持つ。この構造により、より深く複雑なネットワークでの効果的な特徴抽出が可能となる[3][4]。

高次劣化モデリング

実世界の画像劣化は、カメラのブレ、センサーノイズ、JPEG圧縮、画像編集、インターネット送信等の複雑な組み合わせである[2]。Real-ESRGANでは、リンギングとオーバーシュートアーティファクトに対処するためのsincフィルタを導入している[1][2]。

技術的特徴

RRDBNetアーキテクチャ

プログラムで実装されているRRDBNetは、Residual Dense Block(RDB)を多階層化したRRDBを基本単位とする。各RDBは密結合畳み込み層で構成され、LeakyReLU活性化関数を使用する。グローバルスキップ接続により勾配消失問題を軽減し、深いネットワークでの安定した学習を実現する[4]。

マルチモデル対応

3種類の学習済みモデルを提供している:RealESRGAN_x2plus(12チャンネル入力、汎用実写画像向け)、RealESRGAN_x4plus(3チャンネル入力、標準品質)、RealESRGAN_x4plus_anime_6B(6ブロック構造、アニメ画像特化)。

実装の特色

リアルタイム品質評価

処理された各フレームに対してPSNR(Peak Signal-to-Noise Ratio)とSSIM(Structural Similarity Index Measure)を計算し、Lanczos4補間との比較による品質評価を実行する。

マルチメディア統合処理

FFmpegとの連携により、処理済みフレームと元動画の音声を結合してMP4形式で出力する。フレームレート自動検出機能により、元動画の時間軸特性を保持した動画生成を実現する。

参考文献

[1] Wang, X., Xie, L., Dong, C., & Shan, Y. (2021). Real-ESRGAN: Training Real-World Blind Super-Resolution with Pure Synthetic Data. In Proceedings of the IEEE/CVF International Conference on Computer Vision (pp. 1905-1914). https://arxiv.org/abs/2107.10833

[2] Wang, X., Xie, L., Dong, C., & Shan, Y. (2021). Real-ESRGAN: Training Real-World Blind Super-Resolution with Pure Synthetic Data. IEEE Conference Publication. https://ieeexplore.ieee.org/document/9607421

[3] Wang, X., Yu, K., Wu, S., Gu, J., Liu, Y., Dong, C., Qiao, Y., & Loy, C. C. (2018). ESRGAN: Enhanced Super-Resolution Generative Adversarial Networks. In The European Conference on Computer Vision Workshops (ECCVW). https://arxiv.org/abs/1809.00219

[4] Zhang, Y., Tian, Y., Kong, Y., Zhong, B., & Fu, Y. (2018). Residual Dense Network for Image Super-Resolution. In Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition (CVPR). https://arxiv.org/abs/1802.08797

ソースコード


# プログラム名: Real-ESRGAN超解像プログラム
# 特徴技術名: Real-ESRGAN (Real-World Enhanced Super-Resolution Generative Adversarial Network)
# 出典: Wang, X., Xie, L., Dong, C., & Shan, Y. (2021). Real-ESRGAN: Training Real-World Blind Super-Resolution with Pure Synthetic Data. In Proceedings of the IEEE/CVF International Conference on Computer Vision (pp. 1905-1914).
# 特徴機能: 実世界劣化に対応した超解像度化。High-order degradation modelingにより、ノイズ、ぼけ、JPEG圧縮アーティファクト等の複雑な劣化を考慮した超解像度化を実現
# 学習済みモデル: RealESRGAN_x2plus (汎用実写画像向け), RealESRGAN_x4plus(汎用4倍超解像度、23 RRDB構造、実写画像向け)、RealESRGAN_x4plus_anime_6B(アニメ特化、6 RRDB構造、アニメ画像向け)
# 方式設計:
#   - 関連利用技術:
#     * BasicSR(画像復元ツールボックス)- Real-ESRGANの基盤フレームワーク、RRDBNetアーキテクチャ提供
#     * OpenCV(コンピュータビジョンライブラリ)- 動画読み込み、フレーム処理、動画出力
#     * PIL(画像処理ライブラリ)- RGB/BGR色空間変換とNumPy配列との相互変換、日本語テキスト描画
#     * FFmpeg(マルチメディア処理ツール)- 動画音声の抽出・結合
#     * scikit-image(画像品質評価)- PSNR・SSIM品質指標計算
#   - 入力と出力: 入力: 動画(0:動画ファイル、1:カメラ、2:サンプル動画)、出力: OpenCV画面でリアルタイム表示、処理結果をresult.txtファイルに保存
#   - 処理手順:
#     1. モデル選択(3種類のReal-ESRGANモデルから選択)
#     2. 選択されたモデルのダウンロードと初期化
#     3. 入力ソース選択(動画ファイル/カメラ/サンプル動画)
#     4. 動画をフレーム単位で読み込み
#     5. 各フレームをReal-ESRGANで超解像度化
#     6. 品質評価指標(PSNR・SSIM)を計算
#     7. 処理済みフレームをOpenCV画面にリアルタイム表示
#     8. 動画入力時は処理済みフレームを連番PNG形式で保存
#     9. ffmpegでPNGと音声をmuxしてMP4出力
#    10. 結果をresult.txtファイルに保存
# その他の重要事項: FFmpegが必要(音声保持機能用)。Windows環境での動作を前提
# 前準備: pip install -U torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
#   pip install basicsr opencv-python pillow numpy requests scikit-image

import torch
import torch.nn as nn
import torch.nn.functional as F
import cv2
import numpy as np
import os
import requests
import subprocess
from PIL import Image, ImageDraw, ImageFont
from pathlib import Path
from skimage.metrics import structural_similarity as ssim
from skimage.metrics import peak_signal_noise_ratio as psnr
import warnings
import tkinter as tk
from tkinter import filedialog
import urllib.request
import time
from datetime import datetime
warnings.filterwarnings('ignore')

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

# RRDBNet実装(Real-ESRGANの実際の構造に対応)
class ResidualDenseBlock(nn.Module):
    def __init__(self, num_feat=64, num_grow_ch=32):
        super(ResidualDenseBlock, self).__init__()
        self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1)
        self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1)
        self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1)
        self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1)
        self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1)
        self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)

    def forward(self, x):
        x1 = self.lrelu(self.conv1(x))
        x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
        x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
        x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
        x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
        return x5 * 0.2 + x

class RRDB(nn.Module):
    def __init__(self, num_feat, num_grow_ch=32):
        super(RRDB, self).__init__()
        self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch)
        self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch)
        self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch)

    def forward(self, x):
        out = self.rdb1(x)
        out = self.rdb2(out)
        out = self.rdb3(out)
        return out * 0.2 + x

class RRDBNet(nn.Module):
    def __init__(self, num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4):
        super(RRDBNet, self).__init__()
        self.scale = scale
        self.conv_first = nn.Conv2d(num_in_ch, num_feat, 3, 1, 1)
        self.body = nn.Sequential(*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)])
        self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
        self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
        self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
        self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
        self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)
        self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)

    def forward(self, x):
        feat = self.conv_first(x)
        body_feat = self.conv_body(self.body(feat))
        feat = feat + body_feat
        feat = self.lrelu(self.conv_up1(F.interpolate(feat, scale_factor=2, mode='nearest')))
        feat = self.lrelu(self.conv_up2(F.interpolate(feat, scale_factor=2, mode='nearest')))
        out = self.conv_last(self.lrelu(self.conv_hr(feat)))
        return out

# モデルダウンロード機能
def download_file_from_url(url, model_dir, progress=True, file_name=None):
    os.makedirs(model_dir, exist_ok=True)
    if file_name is None:
        file_name = url.split('/')[-1]
    file_path = os.path.join(model_dir, file_name)
    if os.path.exists(file_path):
        return file_path

    print(f'ダウンロード中: {url}')
    response = requests.get(url, stream=True)
    response.raise_for_status()

    total_size = int(response.headers.get('content-length', 0))
    downloaded = 0

    with open(file_path, 'wb') as f:
        for chunk in response.iter_content(chunk_size=8192):
            if chunk:
                f.write(chunk)
                downloaded += len(chunk)
                if progress and total_size > 0:
                    percent = (downloaded / total_size) * 100
                    print(f'\rダウンロード進捗: {percent:.1f}%', end='', flush=True)

    if progress:
        print('\nダウンロード完了')
    return file_path

# 定数定義
WEIGHTS_DIR = 'weights'
RESULT_FILE = 'result.txt'
OUTPUT_VIDEO_FILE = 'enhanced_output.mp4'
SAMPLE_FILE = 'vtest.avi'
FONT_PATH = 'C:/Windows/Fonts/meiryo.ttc'
FONT_SIZE = 20
FONT_COLOR = (0, 255, 0)
TEXT_POSITION = (10, 30)

# モデル情報定義(RealESRGAN_x2plusは12チャンネル入力)
MODEL_INFO = {
    'RealESRGAN_x2plus': {
        'name': 'RealESRGAN x2plus(実際は4倍)',
        'description': '汎用実写画像向け、高品質',
        'scale': 4,
        'input_channels': 12,
        'blocks': 23,
        'features': 64,
        'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth',
        'file_size': '約67MB'
    },
    'RealESRGAN_x4plus': {
        'name': 'RealESRGAN x4plus',
        'description': '汎用実写画像向け、標準品質',
        'scale': 4,
        'input_channels': 3,
        'blocks': 23,
        'features': 64,
        'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth',
        'file_size': '約67MB'
    },
    'RealESRGAN_x4plus_anime_6B': {
        'name': 'RealESRGAN x4plus Anime 6B',
        'description': 'アニメ画像特化、軽量モデル',
        'scale': 4,
        'input_channels': 3,
        'blocks': 6,
        'features': 64,
        'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth',
        'file_size': '約17MB'
    }
}

print('=== Real-ESRGAN動画品質改善プログラム ===')
print('このプログラムは、Real-ESRGANで動画の超解像度化を行います')
print('操作方法:')
print('  q キー: プログラム終了')
print('')

# フォントチェック
if not os.path.exists(FONT_PATH):
    print('エラー: Meiryoフォントが見つかりません')
    exit()

# FFmpeg/ffprobe利用可能性チェック
FFMPEG_AVAILABLE = False
try:
    subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
    FFMPEG_AVAILABLE = True
except Exception:
    pass

FFPROBE_AVAILABLE = False
try:
    subprocess.run(['ffprobe', '-version'], capture_output=True, check=True)
    FFPROBE_AVAILABLE = True
except Exception:
    pass

# システム環境に応じた設定
if torch.cuda.is_available():
    gpu_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3
    if gpu_memory >= 8:
        TILE_SIZE = 512
        USE_HALF = True
    elif gpu_memory >= 4:
        TILE_SIZE = 256
        USE_HALF = True
    else:
        TILE_SIZE = 128
        USE_HALF = False
else:
    TILE_SIZE = 64
    USE_HALF = False

# モデル選択
print('=== モデル選択 ===')
models = list(MODEL_INFO.keys())

for i, model_key in enumerate(models, 1):
    info = MODEL_INFO[model_key]
    print(f'{i}. {info["name"]}')
    print(f'   説明: {info["description"]}')
    print(f'   スケール: {info["scale"]}倍')
    print()

while True:
    try:
        choice = input(f'モデルを選択してください (1-{len(models)}): ')
        choice_idx = int(choice) - 1
        if 0 <= choice_idx < len(models):
            MODEL_NAME = models[choice_idx]
            break
        else:
            print(f'1から{len(models)}の間で選択してください')
    except ValueError:
        print('数値を入力してください')

model_info = MODEL_INFO[MODEL_NAME]
SCALE_FACTOR = model_info['scale']

# デバイス切り替え用ヘルパー関数(将来の拡張性を考慮)
def switch_to_cpu_on_oom(model, device):
    """GPUメモリ不足時にCPUに切り替える共通処理"""
    print('GPU メモリ不足、CPUに切り替えます...')
    torch.cuda.empty_cache()
    model = model.cpu()
    device = torch.device('cpu')
    return model, device

# Real-ESRGANエンハンサークラス
class RealESRGANer:
    def __init__(self, scale, model_path, model, tile=0, tile_pad=10, pre_pad=0, half=True, device=None, input_channels=3):
        self.scale = scale
        self.tile_size = tile
        self.tile_pad = tile_pad
        self.pre_pad = pre_pad
        self.half = half
        self.input_channels = input_channels
        self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') if device is None else device

        loadnet = torch.load(model_path, map_location=torch.device('cpu'))
        keyname = 'params_ema' if 'params_ema' in loadnet else 'params'
        model.load_state_dict(loadnet[keyname], strict=True)
        model.eval()
        self.model = model.to(self.device)
        if self.half and self.device.type == 'cuda':
            self.model = self.model.half()

    def enhance(self, img, outscale=None):
        if outscale is None:
            outscale = self.scale

        img = img.astype(np.float32)
        if np.max(img) > 256:
            max_range = 65535
            img = img / max_range
        else:
            max_range = 255
            img = img / max_range

        if len(img.shape) == 2:
            img = np.expand_dims(img, axis=2)
        if img.shape[2] == 4:
            img = img[:, :, :3]

        h, w = img.shape[0:2]

        # 12チャンネルモデルの場合、入力を12チャンネルに拡張
        if self.input_channels == 12:
            img_12ch = np.concatenate([img, img, img, img], axis=2)
            img = torch.from_numpy(np.transpose(img_12ch, (2, 0, 1))).float()
        else:
            img = torch.from_numpy(np.transpose(img, (2, 0, 1))).float()

        img = img.unsqueeze(0).to(self.device)
        if self.half and self.device.type == 'cuda':
            img = img.half()

        try:
            with torch.no_grad():
                output = self.model(img)
        except RuntimeError as e:
            if 'out of memory' in str(e).lower():
                self.model, self.device = switch_to_cpu_on_oom(self.model, self.device)
                img = img.cpu()
                with torch.no_grad():
                    output = self.model(img)
            else:
                raise e

        output = output.data.squeeze().float().cpu().clamp_(0, 1).numpy()
        output = np.transpose(output, (1, 2, 0))

        if outscale != self.scale:
            output = cv2.resize(output, (int(w * outscale), int(h * outscale)), interpolation=cv2.INTER_LANCZOS4)

        output = (output * max_range).round().astype(np.uint8)
        return output, None

# モデルダウンロード
weights_dir = Path(WEIGHTS_DIR)
weights_dir.mkdir(exist_ok=True)
model_path = weights_dir / f'{MODEL_NAME}.pth'
if not model_path.exists():
    print(f'モデル {model_info["name"]} をダウンロード中...')
    download_file_from_url(model_info['url'], model_dir=str(weights_dir), progress=True, file_name=f'{MODEL_NAME}.pth')

# モデル初期化
model = RRDBNet(
    num_in_ch=model_info['input_channels'],
    num_out_ch=3,
    num_feat=model_info['features'],
    num_block=model_info['blocks'],
    num_grow_ch=32,
    scale=model_info['scale']
)

upsampler = RealESRGANer(
    scale=SCALE_FACTOR,
    model_path=str(model_path),
    model=model,
    tile=TILE_SIZE,
    tile_pad=10,
    pre_pad=0,
    half=USE_HALF,
    device=device,
    input_channels=model_info['input_channels']
)

# フォント設定
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)

# 品質評価の蓄積
frame_count = 0
results_log = []

def video_frame_processing(frame):
    global frame_count
    current_time = time.time()
    frame_count += 1

    # 推論実行
    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    enhanced_frame, _ = upsampler.enhance(np.array(frame_rgb), outscale=None)
    enhanced_frame_bgr = cv2.cvtColor(enhanced_frame, cv2.COLOR_RGB2BGR)

    # 元の低解像度画像と超解像度化後の画像のサイズを合わせて比較
    original_resized = cv2.resize(frame, (enhanced_frame_bgr.shape[1], enhanced_frame_bgr.shape[0]), interpolation=cv2.INTER_LANCZOS4)

    # PSNR/SSIM計算
    psnr_val = psnr(original_resized, enhanced_frame_bgr, data_range=255)
    ssim_val = ssim(original_resized, enhanced_frame_bgr, channel_axis=2, data_range=255)

    # 日本語テキスト描画
    info_text = f'フレーム: {frame_count} | PSNR (vs Lanczos4): {psnr_val:.2f}dB | SSIM (vs Lanczos4): {ssim_val:.4f}'
    img_pil = Image.fromarray(cv2.cvtColor(enhanced_frame_bgr, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    draw.text(TEXT_POSITION, info_text, font=font, fill=FONT_COLOR)
    processed_frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

    result = f'解像度: {frame.shape[1]}x{frame.shape[0]} → {enhanced_frame_bgr.shape[1]}x{enhanced_frame_bgr.shape[0]}, PSNR (vs Lanczos4): {psnr_val:.2f}dB, SSIM (vs Lanczos4): {ssim_val:.4f}'

    return processed_frame, result, current_time

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

choice = input("選択: ")

if choice == '0':
    root = tk.Tk()
    root.withdraw()
    path = filedialog.askopenfilename()
    if not path:
        exit()
    cap = cv2.VideoCapture(path)
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    if not cap.isOpened():
        cap = cv2.VideoCapture(0)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
else:
    # サンプル動画ダウンロード・処理
    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('動画ファイル・カメラを開けませんでした')
    exit()

# 連番画像保存ディレクトリ(動画入力時のみ)
frames_dir = None
if choice != '1':
    if not (FFMPEG_AVAILABLE and FFPROBE_AVAILABLE):
        print('警告: ffmpeg/ffprobeが見つかりません。動画出力機能は利用できません')
    else:
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        frames_dir = Path(f'frames_{timestamp}')
        frames_dir.mkdir(parents=True, exist_ok=True)

# メイン処理
print('\n=== 動画処理開始 ===')
print('操作方法:')
print('  q キー: プログラム終了')
try:
    while True:
        ret, frame = cap.read()
        if not ret:
            break

        MAIN_FUNC_DESC = "Real-ESRGAN超解像度化"
        processed_frame, result, current_time = video_frame_processing(frame)
        cv2.imshow(MAIN_FUNC_DESC, processed_frame)
        if choice == '1':  # カメラの場合
            print(datetime.fromtimestamp(current_time).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], result)
        else:  # 動画ファイルの場合
            print(frame_count, result)
        results_log.append(result)

        # 動画入力の場合は連番PNGで保存
        if choice != '1' and frames_dir is not None:
            save_path = frames_dir / f'{frame_count:06d}.png'
            cv2.imwrite(str(save_path), processed_frame)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
finally:
    print('\n=== プログラム終了 ===')
    cap.release()
    cv2.destroyAllWindows()

    # 動画mux(動画入力時のみ)
    if choice != '1' and frames_dir is not None and FFMPEG_AVAILABLE and FFPROBE_AVAILABLE:
        original_path = path if choice == '0' else SAMPLE_FILE

        # ffprobe_get_framerate処理をインライン化
        cmd = [
            'ffprobe', '-v', 'error',
            '-select_streams', 'v:0',
            '-show_entries', 'stream=r_frame_rate,avg_frame_rate',
            '-of', 'default=nw=1:nk=1',
            original_path
        ]
        rate = None
        try:
            res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
            if res.returncode == 0:
                lines = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
                rate = lines[0] if lines else None
                if rate in (None, '0/0', 'N/A', '0', ''):
                    rate = lines[1] if len(lines) > 1 else None
                if rate in (None, '0/0', 'N/A', '0', ''):
                    rate = None
        except Exception:
            pass

        if rate:
            cmd = [
                'ffmpeg',
                '-y',
                '-framerate', rate,
                '-i', str(frames_dir / '%06d.png'),
                '-i', original_path,
                '-map', '0:v',
                '-map', '1:a?',
                '-shortest',
                '-c:v', 'libx264',
                '-pix_fmt', 'yuv420p',
                '-c:a', 'aac',
                OUTPUT_VIDEO_FILE
            ]
            result = subprocess.run(cmd, capture_output=True)
            if result.returncode == 0 and os.path.exists(OUTPUT_VIDEO_FILE):
                print(f'動画を{OUTPUT_VIDEO_FILE}に保存しました')

    # サンプル動画の削除
    if choice == '2' and os.path.exists(SAMPLE_FILE):
        os.remove(SAMPLE_FILE)

    if results_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            f.write('=== 結果 ===\n')
            f.write(f'処理フレーム数: {frame_count}\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('\n')
            f.write('\n'.join(results_log))
        print(f'\n処理結果をresult.txtに保存しました')