イラストACにてイラストも公開中。まずはここのブログでチェック!!欲しいイラストがあれば無料でダウンロード出来ますのでDownloadへGo!

【完全無料】AIで画像を4倍高画質化!「AI HighQuality Photo」を自作してみた【Real-ESRGAN搭載】

【完全無料】AIで画像を4倍高画質化!「AI HighQuality Photo」を自作してみた【Real-ESRGAN搭載】

1. 導入:こんな悩みありませんか?

  • 昔描いたイラストのサイズが小さすぎて使いにくい

  • AI生成した画像がボケていて、もっとクッキリさせたい

  • Photoshopのサブスクを契約していないけど、プロ級の高画質化がしたい

そんな悩みを1クリックで解決するツールを作りました! が、作ったのちにPhotoshopは「スーパー解像度」や「ニューラルフィルター」といった非常に強力なAI補正機能が備わっていました。似たようなことできるやん! なんて思ったのは秘密です。なので、直感的に綺麗にしたい方、Photoshopを持ってない方は重宝するかもしれませんね。

アイキャッチはイメージ図(ダミー)ですが、搭載しているAIモデルは実際にプロの現場でも使われる強力なものらしいです。ぜひお手元の『ちょっと荒い画像』で、本物の威力を試してみてください。

2. ツールの特徴

最強クラスのAI「Real-ESRGAN」による4倍高画質化

本アプリの核となるのは、世界的に評価の高い高画質化アルゴリズム「Real-ESRGAN」です。 一般的な画像拡大(バイキュービック法など)は、周囲の画素を混ぜて「ぼかす」ことでサイズを大きくしますが、本アプリが採用する独自のAIモデル「RRDBNet」は全く異なります。

AIが画像の特徴を学習・推論し、本来そこにあるはずの質感やエッジ(輪郭)を「再構築」します。これにより、低解像度の写真やイラストも、まるで最初から高解像度で作成されたかのような、驚くほどクッキリとした鮮明な4倍サイズへと生まれ変わります。

この荒い画像がここまで綺麗な画像になっちゃいます!

迷わない、止まらない。究極の直感操作

多機能すぎる画像編集ソフトとは異なり、本アプリは「高画質化」という目的だけに特化しています。 使い方は極めてシンプル。加工したい画像をアプリ画面にドラッグ&ドロップし、仕上がりを確認したら「結果を保存」をクリックするだけです。

専門知識は一切不要で、誰でも起動したその瞬間から、プロ級の画像補正技術を使いこなすことができます。

プライバシーを守る「完全ローカル動作」

昨今のAIサービスの多くは、画像をクラウド上のサーバーに送信して処理を行いますが、本アプリは「完全ローカル完結型」です。 すべての計算はお使いのPC内部(GPUやCPU)で行われるため、大切な写真や機密性の高いイラストがインターネット経由で外部に漏洩する心配は一切ありません。

あなたのPCのパワー(特にNVIDIA製GPUなど)をフルに活用し、オフライン環境でも高速・安全に処理を実行します。

理想の仕上がりを追求する「スライダー調整機能」

AIによる補正は強力ですが、時には「少し自然に残したい」「もっとパキッとさせたい」といった微調整が必要になることもあります。 本アプリにはAIの適用強度をリアルタイムに変更できるスライダーを搭載しています。

  • 強度を下げれば: 元の画像のニュアンスを大切にした、自然で柔らかな仕上がりに。

  • 強度を上げれば: ノイズを徹底的に除去し、アニメの線画やディテールを強調した力強い仕上がりに。

プレビュー画面を見ながらスライダーを動かすだけで、あなたの理想通りの一枚を自由自在に作り出すことが可能です。

3. 使い方(かんたん3ステップ)

  1. 実行ファイルを起動する。

  2. 拡大したい画像を画面にドラッグ&ドロップ。

  3. AI強度を調整して「結果を保存」をクリック!

💡 開発者メモ 中身に本格的なAIを詰め込んでいるため、ファイルサイズは約390MBと少し大きめです。ですが、その分追加のインストールなしで、プロ級の処理がすぐに行えます!

4. ダウンロードはこちら

[ダウンロード:AI_HighQuality_Photo.exe] 容量が約390MB程あります
※Windows用 (RTX4060等のGPU搭載PC以上で推奨ですが、動作はするはずです)

【重要】ダウンロード時のセキュリティ警告について

本アプリは個人開発の実行ファイル(.exe)であるため、ダウンロード時や起動時にWindows Defenderやアンチウイルスソフトによって「一般的なダウンロードではない」「認識されないアプリ」として警告が表示される場合があります。

これは、大手メーカーのようなデジタル署名を行っていない自作プログラムにおいて必ず発生する挙動であり、ウイルスやマルウェアが含まれているものではありません。

警告が出た場合の対処法
  • ブラウザ(Chrome等)で警告が出た場合: 「破棄」ではなく「継続」や「保存」を選択してください。

  • 起動時に「WindowsによってPCが保護されました」と出た場合: 「詳細情報」をクリックし、表示された「実行」ボタンを押すと起動できます。

本アプリは完全ローカル動作であり、外部との通信はAIモデルの初回ダウンロード時(GitHub公式リポジトリへのアクセス)以外、一切行いません。安心してお使いください。

5. まとめ

自分が欲しかった「最高にシンプルな高画質化ツール」を形にしてみました。イラストレーターの方や、写真の補正に困っている方はぜひ活用してみてください! 最も、Photoshopがある方は必要ないかもしれませんがね。


【全コードを表示】AI_HighQuality_Photo.py
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinterdnd2 import DND_FILES, TkinterDnD
import cv2
import numpy as np
from realesrgan import RealESRGANer
from basicsr.archs.rrdbnet_arch import RRDBNet
from skimage.transform import resize
import os
import threading
import sys
from PIL import Image, ImageTk
import queue
import time

# --- グローバル変数 ---
MODEL_FILENAME = "RealESRGAN_x4plus.pth" # モデルファイル名
upsampler_model = None
PREVIEW_SIZE = (350, 350)
result_queue = queue.Queue()
original_filepath, ai_processed_image, simple_processed_image, final_blended_image = None, None, None, None
magnifier_window, magnifier_label = None, None
preview_geo = {}
tk_magnifier_img = None
MAGNIFIER_SIZE = 150
magnifier_source_image = None
slider_update_job = None
last_magnifier_update_time, THROTTLE_INTERVAL = 0, 0.03
is_magnifier_active = False

# --- ファイルパス解決のためのヘルパー関数 ---
def resource_path(relative_path):
    """
    リソースへの絶対パスを取得します。開発時とPyInstaller実行時の両方で機能します。
    """
    try:
        # PyInstallerは一時フォルダを作成し、そのパスを_MEIPASSに格納します
        base_path = sys._MEIPASS
    except Exception:
        # 開発環境ではスクリプトのディレクトリを使用
        base_path = os.path.abspath(".")
    return os.path.join(base_path, relative_path)

# --- 画像処理・コアロジック ---
def setup_model():
    global upsampler_model
    if upsampler_model is not None: return upsampler_model
    try:
        # MODEL_PATH を resource_path を使って設定
        model_file_path = resource_path(MODEL_FILENAME)
        if not os.path.exists(model_file_path):
            # モデルファイルが見つからない場合のエラーハンドリングを追加
            raise FileNotFoundError(f"モデルファイルが見つかりません: {model_file_path}")

        model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
        # RealESRGANerの初期化時にmodel_pathを渡す
        upsampler = RealESRGANer(scale=4, model_path=model_file_path, model=model, tile=0, tile_pad=10, pre_pad=0, half=False)
        upsampler_model = upsampler
        return upsampler_model
    except Exception as e:
        result_queue.put(('error', f"モデルの読み込みに失敗しました。\n\nエラー: {e}"))
        return None

def upscale_image_thread(input_path, upsampler):
    try:
        # OpenCVで画像を読み込む際は、バイナリモードで開く必要がある
        with open(input_path, 'rb') as f: img_binary = np.fromfile(f, dtype=np.uint8)
        img = cv2.imdecode(img_binary, cv2.IMREAD_UNCHANGED)
        if img is None: raise ValueError("画像の読み込みに失敗")

        # Real-ESRGANによる高画質化
        ai_output, _ = upsampler.enhance(img, outscale=4)

        # スケールダウンした元画像(プレビュー用)
        h, w = img.shape[:2]
        target_shape = (h * 4, w * 4) # 元画像の4倍のサイズにリサイズ
        img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        simple_output_float = resize(img_rgb, target_shape, order=3, preserve_range=True, anti_aliasing=True)
        simple_output_rgb = simple_output_float.astype(np.uint8)
        simple_output = cv2.cvtColor(simple_output_rgb, cv2.COLOR_RGB2BGR)

        result_queue.put(('success', (ai_output, simple_output)))
    except Exception as e:
        result_queue.put(('error', f"高画質化処理中にエラーが発生しました。\n{e}"))

# --- GUI関連の関数 ---
def update_preview_panel(panel, cv2_image, save_geo=False):
    global preview_geo
    try:
        h, w = cv2_image.shape[:2]
        aspect_ratio = w / h
        # PREVIEW_SIZE に収まるようにリサイズ
        if aspect_ratio > 1: # 横長画像
            new_w, new_h = PREVIEW_SIZE[0], int(PREVIEW_SIZE[0] / aspect_ratio)
        else: # 縦長画像
            new_h, new_w = PREVIEW_SIZE[1], int(PREVIEW_SIZE[1] * aspect_ratio)

        # 中央に配置するための背景作成
        resized_img = cv2.resize(cv2_image, (new_w, new_h), interpolation=cv2.INTER_AREA)
        background = np.zeros((PREVIEW_SIZE[1], PREVIEW_SIZE[0], 3), dtype=np.uint8)
        x_offset, y_offset = (PREVIEW_SIZE[0] - new_w) // 2, (PREVIEW_SIZE[1] - new_h) // 2
        background[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized_img

        if save_geo:
            # 虫眼鏡表示のために、プレビューパネル内の画像のジオメトリを保存
            preview_geo = {'w': new_w, 'h': new_h, 'x_off': x_offset, 'y_off': y_offset}

        # Tkinterで表示できるように変換
        img_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
        pil_img = Image.fromarray(img_rgb)
        tk_img = ImageTk.PhotoImage(image=pil_img)
        panel.config(image=tk_img, text=""); panel.image = tk_img # 画像を更新
    except Exception:
        panel.config(text="プレビュー表示エラー", image='') # エラー時はテキスト表示

def _perform_update_preview():
    """
    スライダーの値に基づいて、AI処理後画像と元画像(4倍スケール)をブレンドし、
    プレビューパネルを更新します。
    """
    global final_blended_image, magnifier_source_image
    if ai_processed_image is None or simple_processed_image is None: return

    strength = ai_strength_slider.get()
    # cv2.addWeightedでAI処理画像と単純にスケールアップした元画像をブレンド
    final_blended_image = cv2.addWeighted(ai_processed_image, strength, simple_processed_image, 1.0 - strength, 0)

    # プレビュー表示用に中央部分をクロップ
    h, w = final_blended_image.shape[:2]
    center_x, center_y, crop_size = w // 2, h // 2, min(w, h) // 2
    crop_x1, crop_y1 = max(0, center_x - crop_size), max(0, center_y - crop_size)
    crop_x2, crop_y2 = min(w, center_x + crop_size), min(h, center_y + crop_size)
    after_cropped_img = final_blended_image[crop_y1:crop_y2, crop_x1:crop_x2]

    magnifier_source_image = after_cropped_img # 虫眼鏡表示用の元画像として保存
    update_preview_panel(preview_after, after_cropped_img, save_geo=True)

def on_slider_move(value):
    """
    スライダーが動いたときに、プレビューの更新をスケジュールします。
    短時間での連続更新を防ぐためにディレイ(200ms)を設けています。
    """
    global slider_update_job
    if slider_update_job:
        app.after_cancel(slider_update_job) # 前回のスケジュールをキャンセル
    slider_update_job = app.after(200, _perform_update_preview) # 200ms後に_perform_update_previewを実行

def start_processing(filepath):
    """
    指定されたファイルパスの画像を処理開始します。
    """
    global ai_processed_image, simple_processed_image, final_blended_image, original_filepath, preview_geo, magnifier_source_image
    ai_processed_image, simple_processed_image, final_blended_image, original_filepath = None, None, None, filepath
    preview_geo, magnifier_source_image = {}, None # 各種状態をリセット

    # UIの状態を更新
    save_button.config(state=tk.DISABLED)
    process_button.config(state=tk.DISABLED, text="処理中...")

    try:
        # 画像を読み込み、中央部分をプレビュー表示
        with open(filepath, 'rb') as f: img_binary = np.fromfile(f, dtype=np.uint8)
        img_before = cv2.imdecode(img_binary, cv2.IMREAD_UNCHANGED)
        if img_before is None: raise ValueError("ファイルが読み込めません")

        h, w = img_before.shape[:2]
        center_x, center_y, crop_size = w // 2, h // 2, min(w, h) // 2
        crop_x1, crop_y1 = max(0, center_x - crop_size), max(0, center_y - crop_size)
        crop_x2, crop_y2 = min(w, center_x + crop_size), min(h, center_y + crop_size)
        before_cropped_img = img_before[crop_y1:crop_y2, crop_x1:crop_x2]
        update_preview_panel(preview_before, before_cropped_img)
        before_label.config(text="処理前 (中央を拡大)")
    except Exception as e:
        messagebox.showerror("エラー", f"画像プレビューの生成に失敗しました: {e}")
        reset_ui_state() # UIを初期状態に戻す
        return

    # AI処理中の表示
    preview_after.config(text="AIが高画質化中...", image='')
    after_label.config(text="処理後")

    # モデルのセットアップと高画質化スレッドの開始
    upsampler = setup_model()
    if not upsampler: # モデルセットアップに失敗した場合
        reset_ui_state()
        return

    thread = threading.Thread(target=upscale_image_thread, args=(filepath, upsampler))
    thread.daemon = True # メインスレッド終了時に子スレッドも終了させる
    thread.start()

def check_queue():
    """
    結果キューを定期的にチェックし、処理結果(成功またはエラー)をUIに反映させます。
    """
    global ai_processed_image, simple_processed_image
    try:
        status, data = result_queue.get_nowait() # キューから結果を取得(なければ例外発生)
        if status == 'success':
            # 処理成功時
            ai_processed_image, simple_processed_image = data
            _perform_update_preview() # プレビュー更新
            save_button.config(state=tk.NORMAL) # 保存ボタンを有効化
            process_button.config(state=tk.NORMAL, text="別の画像を処理する") # ボタンのテキストと状態を更新
        elif status == 'error':
            # 処理失敗時
            messagebox.showerror("処理エラー", data) # エラーメッセージ表示
            reset_ui_state() # UIを初期状態に戻す
    except queue.Empty:
        # キューが空の場合は何もしない
        pass
    finally:
        # 100ms後に再度check_queueを呼び出すようにスケジュール
        app.after(100, check_queue)

def save_result():
    """
    最終的にブレンドされた画像をファイルに保存します。
    """
    if final_blended_image is None or original_filepath is None: return

    # 保存ファイル名の生成 (元ファイル名 + 強度)
    filename, _ = os.path.splitext(os.path.basename(original_filepath))
    strength = int(ai_strength_slider.get() * 100)
    default_filename = f"{filename}_RealESRGAN_strength{strength}.png"

    # ファイル保存ダイアログを表示
    save_path = filedialog.asksaveasfilename(
        initialdir=os.path.dirname(original_filepath), # 元ファイルのディレクトリを初期ディレクトリに
        initialfile=default_filename,
        defaultextension=".png",
        filetypes=[("PNGファイル", "*.png"), ("すべてのファイル", "*.*")]
    )

    if save_path: # ファイルパスが指定された場合
        try:
            # 画像をPNG形式でエンコード
            is_success, img_encoded = cv2.imencode(".png", final_blended_image)
            if not is_success: raise ValueError("画像のエンコードに失敗")
            # ファイルに書き込み
            img_encoded.tofile(save_path)
            messagebox.showinfo("成功", f"画像を保存しました。\n{save_path}")
        except Exception as e:
            messagebox.showerror("保存エラー", f"ファイルの保存中にエラーが発生しました。\n{e}")

def select_file_and_process():
    """
    ファイル選択ダイアログを開き、選択された画像を処理します。
    """
    filepath = filedialog.askopenfilename(
        filetypes=[("画像ファイル", "*.png;*.jpg;*.jpeg;*.bmp;*.webp")]
    )
    if filepath:
        start_processing(filepath)

def drop(event):
    """
    ドラッグ&ドロップされたファイルのパスを取得し、処理を開始します。
    """
    filepath = get_dropped_filepath(event.data)
    if os.path.isfile(filepath):
        start_processing(filepath)

def reset_ui_state():
    """
    UIのボタンやテキストを初期状態に戻します。
    """
    process_button.config(state=tk.NORMAL, text="画像を選択するか、ここにドラッグ&ドロップ")
    preview_after.config(text="ここに結果が表示されます", image='')

def get_dropped_filepath(event_data):
    """
    tkinterdnd2から渡されるドロップされたファイルパスを整形します。
    Windows環境で '{...}' で囲まれる場合に対応します。
    """
    path = event_data
    if sys.platform == "win32" and path.startswith('{') and path.endswith('}'):
        path = path[1:-1] # '{'と'}'を取り除く
    return path

def create_magnifier():
    """
    虫眼鏡表示用のToplevelウィンドウを作成します。
    """
    global magnifier_window, magnifier_label
    magnifier_window = tk.Toplevel(app)
    magnifier_window.overrideredirect(True) # ウィンドウ枠を非表示
    magnifier_window.wm_attributes("-topmost", True) # 常に最前面に表示
    # Canvasウィジェットを作成し、虫眼鏡の表示領域とする
    canvas = tk.Canvas(magnifier_window, width=MAGNIFIER_SIZE, height=MAGNIFIER_SIZE, highlightthickness=1)
    canvas.configure(highlightbackground="black") # 枠線の色
    canvas.pack()
    magnifier_label = canvas
    magnifier_window.withdraw() # 最初は非表示

def show_magnifier(event):
    """
    マウスカーソルがプレビュー領域に入ったときに虫眼鏡を表示し始めます。
    """
    global is_magnifier_active
    if magnifier_source_image is None: return # 表示する画像がなければ何もしない
    is_magnifier_active = True
    update_magnifier(event) # マウス位置に合わせて更新

def hide_magnifier(event):
    """
    マウスカーソルがプレビュー領域から出たときに虫眼鏡を非表示にします。
    """
    global is_magnifier_active
    is_magnifier_active = False
    magnifier_window.withdraw() # 虫眼鏡ウィンドウを非表示

def update_magnifier(event):
    """
    マウスカーソルの位置に応じて虫眼鏡の内容と位置を更新します。
    """
    if not is_magnifier_active or magnifier_source_image is None or not preview_geo: return # アクティブでなく、画像やジオメトリ情報がない場合は何もしない

    magnifier_window.deiconify() # 虫眼鏡ウィンドウを表示

    # スロットリング: 短時間での連続更新を防ぐ
    global last_magnifier_update_time
    current_time = time.time()
    if (current_time - last_magnifier_update_time) < THROTTLE_INTERVAL: return
    last_magnifier_update_time = current_time

    # マウスカーソルがプレビュー領域外に出たら非表示にする
    if not (preview_geo['x_off'] < event.x < preview_geo['x_off'] + preview_geo['w'] and \
            preview_geo['y_off'] < event.y < preview_geo['y_off'] + preview_geo['h']):
        hide_magnifier(event)
        return

    # マウスカーソル位置から、拡大元画像の中心座標を計算
    relative_x, relative_y = event.x - preview_geo['x_off'], event.y - preview_geo['y_off']
    src_h, src_w = magnifier_source_image.shape[:2]
    scale_w, scale_h = src_w / preview_geo['w'], src_h / preview_geo['h']
    center_x, center_y = int(relative_x * scale_w), int(relative_y * scale_h)

    # 虫眼鏡で拡大する範囲を計算 (MAGNIFIER_SIZE とプレビューサイズから相対的に決定)
    # magnification_factor = MAGNIFIER_SIZE / (preview_geo['w'] / src_w) # プレビュー上での拡大率
    # CROP_RADIUS = int(MAGNIFIER_SIZE / magnification_factor / 2) # 拡大中心からの半径
    CROP_RADIUS = int(src_w / (MAGNIFIER_SIZE / 20)) # 簡易的な計算

    x1, y1 = max(0, center_x - CROP_RADIUS), max(0, center_y - CROP_RADIUS)
    x2, y2 = min(src_w, center_x + CROP_RADIUS), min(src_h, center_y + CROP_RADIUS)

    # 元画像から該当部分をクロップ
    cropped_img = magnifier_source_image[y1:y2, x1:x2]
    # INTER_NEAREST(最近傍補間)で拡大(ピクセル感を出すため)
    magnified_img = cv2.resize(cropped_img, (MAGNIFIER_SIZE, MAGNIFIER_SIZE), interpolation=cv2.INTER_NEAREST)

    # Tkinterで表示できるように変換
    img_rgb = cv2.cvtColor(magnified_img, cv2.COLOR_BGR2RGB)
    pil_img = Image.fromarray(img_rgb)
    global tk_magnifier_img
    tk_magnifier_img = ImageTk.PhotoImage(image=pil_img)

    # Canvasに画像を描画
    magnifier_label.create_image(0, 0, anchor=tk.NW, image=tk_magnifier_img)

    # 虫眼鏡ウィンドウをマウスカーソルの位置に追従させる
    panel_abs_x, panel_abs_y = preview_after.winfo_rootx(), preview_after.winfo_rooty()
    cursor_abs_x, cursor_abs_y = panel_abs_x + event.x, panel_abs_y + event.y
    pos_x, pos_y = cursor_abs_x - MAGNIFIER_SIZE // 2, cursor_abs_y - MAGNIFIER_SIZE // 2
    magnifier_window.geometry(f"+{pos_x}+{pos_y}")

    # 十字線を描画
    magnifier_label.delete("crosshair") # 既存の十字線を削除
    magnifier_label.create_line(MAGNIFIER_SIZE/2, 0, MAGNIFIER_SIZE/2, MAGNIFIER_SIZE, fill='red', width=1, tags="crosshair")
    magnifier_label.create_line(0, MAGNIFIER_SIZE/2, MAGNIFIER_SIZE, MAGNIFIER_SIZE/2, fill='red', width=1, tags="crosshair")

# --- GUIの初期化 ---
app = TkinterDnD.Tk() # TkinterDnD.Tk() を使用してドラッグ&ドロップ対応にする
app.title("AI高画質化アプリ (v4.1 Final)")
try:
    # resource_path を使って icon.ico へのパスを解決
    icon_path = resource_path('icon.ico')
    app.iconbitmap(icon_path)
except tk.TclError:
    print("icon.icoが見つからない、または読み込めません。デフォルトアイコンを使用します。")
except FileNotFoundError:
    print("icon.icoファイルが見つかりません。デフォルトアイコンを使用します。")
app.geometry("800x550") # ウィンドウサイズの設定

# --- 画像プレビューフレーム ---
preview_frame = tk.Frame(app)
preview_frame.pack(pady=10, padx=10, fill="both", expand=True)
# グリッドレイアウトの設定 (中央揃えのため weight を設定)
preview_frame.columnconfigure(0, weight=1)
preview_frame.columnconfigure(1, weight=1)
preview_frame.rowconfigure(1, weight=1)

# 処理前画像表示エリア
before_label = tk.Label(preview_frame, text="処理前")
before_label.grid(row=0, column=0)
preview_before = tk.Label(preview_frame, text="ここに処理前の画像が表示されます", relief="sunken", bg="gray20", fg="white", wraplength=300)
preview_before.grid(row=1, column=0, padx=5, pady=5, sticky="nsew")

# 処理後画像表示エリア
after_label = tk.Label(preview_frame, text="処理後 (クリック&ホールドで虫眼鏡)")
after_label.grid(row=0, column=1)
preview_after = tk.Label(preview_frame, text="ここに結果が表示されます", relief="sunken", bg="gray20", fg="white", wraplength=300)
preview_after.grid(row=1, column=1, padx=5, pady=5, sticky="nsew")

# --- コントロールフレーム ---
control_frame = tk.Frame(app, pady=10)
control_frame.pack(fill="x", padx=10)

# AI強度調整スライダー
strength_frame = tk.LabelFrame(control_frame, text="1. AIの強度を調整", padx=10, pady=5)
strength_frame.pack(pady=5, fill="x")
ai_strength_slider = ttk.Scale(strength_frame, from_=0.0, to=1.0, value=1.0, orient="horizontal", command=on_slider_move)
ai_strength_slider.pack(fill="x", padx=5)

# 処理ボタンと保存ボタン
process_frame = tk.Frame(control_frame)
process_frame.pack(pady=10, fill="x")

process_button = tk.Button(process_frame, text="2. 画像を選択するか、ここにドラッグ&ドロップ", command=select_file_and_process, height=2)
process_button.pack(side="left", fill="x", expand=True)
# ドラッグ&ドロップのターゲットとして登録
process_button.drop_target_register(DND_FILES)
process_button.dnd_bind('<>', drop) # ドロップイベントに関数をバインド

save_button = tk.Button(process_frame, text="結果を保存", command=save_result, state=tk.DISABLED) # 初期状態は無効
save_button.pack(side="right", padx=10)

# --- 虫眼鏡ウィンドウの作成 ---
create_magnifier()

# --- イベントバインディング ---
# プレビュー画像上でのマウスイベントを虫眼鏡表示にバインド
preview_after.bind("", show_magnifier) # 左クリック開始
preview_after.bind("", hide_magnifier) # 左クリック終了
preview_after.bind("", update_magnifier) # マウス移動

# --- メインループ開始 ---
app.after(100, check_queue) # キューのチェックを開始
app.mainloop()