uv + Gradio + PyInstallerで、AIデスクトップアプリを作ろう

AI

こんにちは。

今回は開発したAI機能を他の誰かにデスクトップアプリとして配布するための現状最適解と感じている方法についてご紹介します。

結論はタイトルの通りですが、「uv + Gradio +PyInstaller」がおすすめです。

uvを使って開発環境を構築し、GradioでのUI実装、そしてPyInstallerでの配布(exe化)までの流れを、本記事ではまとめて紹介します。

それではよろしくお願いします。

なぜデスクトップアプリを作るのか

AI開発に限らずかもしれませんが、作ったアプリや機能が他人に使われないまま終わってしまうことってありませんか?

私もよくあるのですが、プレゼンやレビューで自分が作ったアプリや機能を自分の手元で披露することはよくあるのですが、他人の環境、特にエンジニア以外のPCで動かしてもらいたいことが多々あったりします。

そんな時、Pythonで作ったコードを動かすためには、Pythonの環境構築から基本的な操作方法までレクチャーするのは非常に大変です。

使う側もそれだけで面倒くさくなってしまって、結局使われないということがあります。

なのでPython環境構築などの面倒な手間を削減し、ダブルクリックだけでアプリが立ち上がるようにするためにもデスクトップアプリ化は有効な手段となります。

またPC上で動かせるので、インターネットが使えない環境でもAIアプリを動かしたい場合などでも有効です。

Pythonの環境構築の決定版「uv」

現在のPythonにおける環境構築ツールにおいて、圧倒的におすすめなのが「uv」です。

uvの何がそんなにすごいのかというと、これまでどのツールでもできなかった「Pythonのバージョンと依存関係の管理をこれ一本でできる」ことが大きなポイントです。

例えばPython標準で搭載されているvenvですが、これはPythonのバージョンを変えることができません。

Pythonのバージョンを変更するためにはpyenvやDockerなどのツールで対応することができますが、今度は依存関係(例としてnumpyやpandasなどのライブラリ)が管理できないという課題がありました。

よってpyenvやDockerでPythonのバージョンを管理し、venvやpip、poetryなどで依存関係を管理するという、複数のツールを組み合わせることでPythonの環境構築を柔軟にやろうとしたというのがこれまでの流れでした。

しかしこれではPythonの環境構築の段階でとても複雑で面倒に感じることでしょう。

これらの悩みを一発で解決してくれる存在が、「uv」なのです。

uvはRustで構築されており、ライブラリのインストール速度がとても速いです。

またGPUを動かすためにCUDAというツールを動かすための環境設定が非常に面倒なのですが、uvでは特定のPyTorchバージョンをインストールすると、自動で設定してくれるのもうれしいポイントです。

なのでuvはPython仮想環境ツールの最適解だと思いますので、ぜひuvを積極的に使いましょう。

Streamlitではなく、「Gradio」の理由

私のブログでは定番中の定番なStreamlitですが、今回はStreamlitはおやすみです。

そして代わりに「Gradio」を使います。

GradioはStable DiffusionやHugging faceでもお馴染みのフレームワークです。

今回Gradioを選んだ理由は大きく以下になります。

  • Streamlitより軽量で高速
  • Tkinterや他言語に比べて学習/メンテナンスコストが低い
  • 見た目がシンプルでスッキリしている(UX的に優れている)

UIが豊富なStreamlitに比べてAIアプリを作るならGradioの方がおすすめです。

逆にユーザーから操作や入力を多く求める場合はStreamlitの方が有利になるでしょう。

Python開発でデスクトップアプリの定番「PyInstaller」

Pythonアプリをデスクトップアプリ化するなら、定番ともいえるツールが「PyInstaller」です。

本当にこれしかないってぐらい一択です。

過去に私の記事でもStreamlit + minicondaで取り上げたことがあるので、良かったらこちらもご参考ください。

Anacondaは条件によってはライセンス契約が必要ですし、容量がでかいためminicondaで当時は対応してますね。

minicondaでもuvの方が有利だと考えるので、本記事はアップデート記事でもあります。

実装手順

お急ぎの方向けに、今回作成したコード一式は以下のGithubになります。

uvによる環境構築

まずは以下のリンクからuvをインストールしましょう。

では早速プロジェクトを作ってみましょう。

適当な名前をつけてディレクトリを用意します。

今回はgradio_desktopとしましょう。

余談ですが、私はCursor環境で話を進めます。

AIコーディングツールは人それぞれこだわりはあるかもしれませんが、何かしらのAIツールは導入することをおすすめします。

本記事でも口述しますが、AIツールがあった方が便利です。

話を戻しまして、まずは以下のコマンドを入力しましょう。

uv python list

すると以下のようにPythonのバージョンリストが表示されるかと思います。

現在ダウンロード済のものと、ダウンロード可能なものがあるので、お好きなものを選びましょう。

最新は3.13ですが、あえて私は3.12のものを使います。

まだインストールされてない場合は以下のコマンドでインストールしましょう。

uv python install 3.12

インストールが完了したら、3.12に以下のコマンドで切り替えます。

uv python pin 3.12

.python-versionというファイルが生成されたと思います。これを開くと3.12と書かれていればOKです。

ではプロジェクトを以下のコマンドで作成します。

uv init

すると必要なファイルが自動的に作成されます。

では早速Gradioという依存関係を追加してみましょう。

uv add gradio

Gradioと間接的に必要な依存関係が自動でインストールされました。

pyproject.tomlを見れば、gradioのバージョンがいくつで管理されてるかが確認できます。

この辺はpoetryとも一緒ですね。

poetryについては私の過去記事もあるのでよかったらご覧ください。

では以下のコマンドで仮想環境をactivateしてみましょう。

(windows)
. .venv/bin/activate
(mac/Linux)
source .venv/bin/activate

新しくコマンド画面を立ち上げる(コマンドターミナル右上の「+」ボタン)でも自動的にactivateされます。

activateされると先頭に(gradio-desktop)となればOKです。

ではちゃんとGradioが動くか、試しにコードを書いてみましょう。

動作確認用なので、雑にCursorに書いてもらいました。

では以下のコマンドで実行しましょう。

python hello.py

すると以下のようなアプリが起動されました。

自動で立ち上がらない場合はターミナルにlocal URLが記載されてると思いますので、そちらからアクセスしてください。

終了する時はターミナルでCtrl +Cです

これで仮想環境の構築と確認は終わりです。

GradioでAIアプリの作成

ここからはGradioを使ってAIアプリを作成します。

既にお好きなGradioで作成したアプリがあれば、この章は飛ばしてもらって構いません。

本記事ではRF-DETRというモデルを使って画像の物体検出をするアプリを作ってみましょう。

まずは仮想環境に追加します。

uv add rfdetr

動作に影響はありませんが、PyTorch2.9以降で発生するwarning(TF32に関する変更予告)がなんとなく毎回出るのは気持ちが悪いので、バージョンを2.8に変更します。

uv add torch==2.8.0

さてRF-DETRの物体検出はどうやるかというと、公式ドキュメントが参考になりそうです。

from rfdetr import RFDETRBase
model = RFDETRBase()
predictions = model.predict(pil_image, threshold=confidence_threshold)

predictionsがどのような構造になっているかは、実際に出力すればわかります。

Detections(xyxy=array([[6.9853134e+01, 2.4844327e+02, 6.4931488e+02, 9.2986096e+02],
       [6.2576270e+02, 7.2710321e+02, 6.9995087e+02, 7.8664832e+02],
       [4.9155235e-01, 3.5481012e+02, 6.4170050e+02, 1.2669675e+03],
       [4.9189568e-01, 6.6448730e+02, 4.4054376e+02, 1.2686859e+03]],
      dtype=float32), mask=None, confidence=array([0.93889666, 0.7368441 , 0.7084036 , 0.6805203 ], dtype=float32), class_id=array([18,  3,  1, 27]), tracker_id=None, data={}, metadata={})

class_idは以下にあります。

あとは物体検出して画像上に可視化する処理を加えましょう。

上記要点を段階的にCursorなどのAIに投げながらコードを生成すると以下のようになります。

import gradio as gr
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from rfdetr import RFDETRBase

COCO_CLASSES = {
    1: "person",
    2: "bicycle",
    3: "car",
    4: "motorcycle",
    5: "airplane",
    6: "bus",
    7: "train",
    8: "truck",
    9: "boat",
    10: "traffic light",
    11: "fire hydrant",
    13: "stop sign",
    14: "parking meter",
    15: "bench",
    16: "bird",
    17: "cat",
    18: "dog",
    19: "horse",
    20: "sheep",
    21: "cow",
    22: "elephant",
    23: "bear",
    24: "zebra",
    25: "giraffe",
    27: "backpack",
    28: "umbrella",
    31: "handbag",
    32: "tie",
    33: "suitcase",
    34: "frisbee",
    35: "skis",
    36: "snowboard",
    37: "sports ball",
    38: "kite",
    39: "baseball bat",
    40: "baseball glove",
    41: "skateboard",
    42: "surfboard",
    43: "tennis racket",
    44: "bottle",
    46: "wine glass",
    47: "cup",
    48: "fork",
    49: "knife",
    50: "spoon",
    51: "bowl",
    52: "banana",
    53: "apple",
    54: "sandwich",
    55: "orange",
    56: "broccoli",
    57: "carrot",
    58: "hot dog",
    59: "pizza",
    60: "donut",
    61: "cake",
    62: "chair",
    63: "couch",
    64: "potted plant",
    65: "bed",
    67: "dining table",
    70: "toilet",
    72: "tv",
    73: "laptop",
    74: "mouse",
    75: "remote",
    76: "keyboard",
    77: "cell phone",
    78: "microwave",
    79: "oven",
    80: "toaster",
    81: "sink",
    82: "refrigerator",
    84: "book",
    85: "clock",
    86: "vase",
    87: "scissors",
    88: "teddy bear",
    89: "hair drier",
    90: "toothbrush",
}

# クラスごとの色を定義
CLASS_COLORS = [
    "red",
    "blue",
    "green",
    "yellow",
    "purple",
    "orange",
    "pink",
    "cyan",
    "magenta",
    "lime",
    "navy",
    "maroon",
    "olive",
    "teal",
    "silver",
    "gray",
    "darkred",
    "darkblue",
    "darkgreen",
    "gold",
    "indigo",
    "coral",
    "hotpink",
    "lightblue",
    "lightgreen",
    "lightcoral",
    "lightgray",
    "mediumblue",
    "mediumgreen",
    "mediumpurple",
    "mediumseagreen",
    "mediumslateblue",
    "mediumturquoise",
    "mediumvioletred",
    "midnightblue",
    "mistyrose",
    "moccasin",
    "navajowhite",
    "oldlace",
    "olivedrab",
    "orangered",
    "orchid",
    "palegoldenrod",
    "palegreen",
    "paleturquoise",
    "palevioletred",
    "papayawhip",
    "peachpuff",
    "peru",
    "plum",
    "powderblue",
    "rosybrown",
    "royalblue",
    "saddlebrown",
    "salmon",
    "sandybrown",
    "seagreen",
    "seashell",
    "sienna",
    "skyblue",
    "slateblue",
    "slategray",
    "snow",
    "springgreen",
    "steelblue",
    "tan",
    "thistle",
    "tomato",
    "turquoise",
    "violet",
    "wheat",
    "whitesmoke",
    "yellowgreen",
]

model = RFDETRBase()


def detect_objects(image, confidence_threshold):
    """
    画像から物体を検出し、結果を可視化した画像を返す
    """
    if image is None:
        return None, "画像がアップロードされていません。"

    try:
        # 画像をPIL形式に変換
        if isinstance(image, np.ndarray):
            pil_image = Image.fromarray(image)
        else:
            pil_image = image

        # 物体検出を実行
        predictions = model.predict(pil_image, threshold=confidence_threshold)

        # 検出結果を可視化した画像を作成
        annotated_image = pil_image.copy()
        draw = ImageDraw.Draw(annotated_image)

        # フォントの設定(システムフォントを使用)
        try:
            font = ImageFont.truetype("arial.ttf", 20)
        except Exception as e:
            print(f"フォントの読み込みに失敗しました: {e}")
            font = ImageFont.load_default()

        # 検出結果のサマリーを作成
        newline = "\n"
        summary = f"検出された物体数: {len(predictions)}{newline}"
        if len(predictions) > 0:
            summary += f"検出された物体:{newline}"

            # Detectionsオブジェクトの場合の処理
            if (
                hasattr(predictions, "xyxy")
                and hasattr(predictions, "confidence")
                and hasattr(predictions, "class_id")
            ):
                # バウンディングボックス、信頼度、クラスIDを取得
                bboxes = predictions.xyxy
                confidences = predictions.confidence
                class_ids = predictions.class_id

                for i in range(len(bboxes)):
                    bbox = bboxes[i]
                    confidence = float(confidences[i])
                    class_id = int(class_ids[i])

                    # バウンディングボックスの座標を取得
                    if len(bbox) >= 4:
                        x1, y1, x2, y2 = bbox[:4]
                        # 座標を整数に変換
                        x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)

                        # クラス名を決定(COCO2017クラス名を使用)
                        class_name = COCO_CLASSES.get(class_id, f"unknown_{class_id}")

                        # 色を決定(クラスIDに基づいて色を選択)
                        color = CLASS_COLORS[class_id % len(CLASS_COLORS)]

                        # バウンディングボックスを描画
                        draw.rectangle([x1, y1, x2, y2], outline=color, width=3)

                        # ラベルを描画
                        label = f"{class_name}: {confidence:.2f}"

                        # ラベルの背景を描画
                        bbox_text = draw.textbbox((x1, y1 - 25), label, font=font)
                        draw.rectangle(bbox_text, fill=color)
                        draw.text((x1, y1 - 25), label, fill="white", font=font)

                        summary += (
                            f"- {class_name}: {confidence:.2f} "
                            f"(座標: [{x1}, {y1}, {x2}, {y2}], 色: {color}){newline}"
                        )
                    else:
                        summary += (
                            f"- Object {i + 1}: {confidence:.2f} "
                            f"(座標データ異常){newline}"
                        )
            else:
                # 従来のタプル形式の場合の処理
                for i, prediction in enumerate(predictions):
                    # タプルの場合の処理
                    if isinstance(prediction, tuple):
                        if len(prediction) >= 2:
                            # バウンディングボックスの座標を取得
                            bbox = prediction[0]
                            confidence = (
                                float(prediction[1])
                                if prediction[1] is not None
                                else 0.0
                            )

                            # バウンディングボックスを描画
                            if (
                                isinstance(bbox, (list, tuple, np.ndarray))
                                and len(bbox) >= 4
                            ):
                                x1, y1, x2, y2 = bbox[:4]
                                # 座標を整数に変換
                                x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)

                                # クラス名と色を決定
                                # RF-DETRの予測結果からクラス情報を取得を試行
                                class_id = i  # 仮のクラスID(実際のクラス情報が取得できない場合)
                                if len(prediction) >= 3:  # クラス情報がある場合
                                    class_id = (
                                        int(prediction[2])
                                        if prediction[2] is not None
                                        else i
                                    )

                                # クラス名を決定(COCO2017クラス名を使用)
                                class_name = COCO_CLASSES.get(
                                    class_id, f"unknown_{class_id}"
                                )

                                # 色を決定(クラスIDに基づいて色を選択)
                                color = CLASS_COLORS[class_id % len(CLASS_COLORS)]

                                # バウンディングボックスを描画
                                draw.rectangle([x1, y1, x2, y2], outline=color, width=3)

                                # ラベルを描画
                                label = f"{class_name}: {confidence:.2f}"

                                # ラベルの背景を描画
                                bbox_text = draw.textbbox(
                                    (x1, y1 - 25), label, font=font
                                )
                                draw.rectangle(bbox_text, fill=color)
                                draw.text((x1, y1 - 25), label, fill="white", font=font)

                                summary += (
                                    f"- {class_name}: {confidence:.2f} "
                                    f"(座標: [{x1}, {y1}, {x2}, {y2}], "
                                    f"色: {color}){newline}"
                                )
                            else:
                                summary += (
                                    f"- Object {i + 1}: {confidence:.2f} "
                                    f"(座標データ異常){newline}"
                                )
                        else:
                            summary += f"- Object {i + 1}: データ形式異常{newline}"
                    # 辞書の場合の処理
                    elif isinstance(prediction, dict):
                        class_name = prediction.get("class", "Unknown")
                        confidence = prediction.get("confidence", 0.0)
                        class_id = hash(class_name) % len(CLASS_COLORS)
                        color = CLASS_COLORS[class_id]
                        summary += (
                            f"- {class_name}: {confidence:.2f} (色: {color}){newline}"
                        )
                    else:
                        summary += f"- Object {i + 1}: {str(prediction)}{newline}"
        else:
            summary += "物体が検出されませんでした。"

        return annotated_image, summary

    except Exception as e:
        return None, f"エラーが発生しました: {str(e)}"


# Gradioインターフェースを作成
demo = gr.Interface(
    fn=detect_objects,
    inputs=[
        gr.Image(label="画像をアップロードしてください", type="pil"),
        gr.Slider(
            minimum=0.1,
            maximum=1.0,
            value=0.5,
            step=0.1,
            label="信頼度閾値",
            info="検出の信頼度の最小値を設定します",
        ),
    ],
    outputs=[gr.Image(label="検出結果"), gr.Textbox(label="検出サマリー", lines=10)],
    title="物体検出アプリ",
    description="画像をアップロードして物体検出を実行します。RF-DETRモデルを使用して物体を検出し、バウンディングボックスで表示します。",
    examples=[["https://media.roboflow.com/dog.jpeg", 0.5]],
)


def main():
    demo.launch(server_name="0.0.0.0", inbrowser=True)


if __name__ == "__main__":
    main()

Streamlitに比べて、少し特徴的な書き方をするGradioですが、AIにコードを書いてもらえばこの辺もスムーズに書いてくれます。

launchの引数だけ少し修正してます。詳しくは以下をご確認ください。

これをpython hello.pyでスクリプトを実行すると以下のような画面が自動で立ち上がります。

RF-DETRでも紹介されていた画像もexampleとして持ってきました。

これで試してみましょう。

上手く物体検出できていますね!

以上でAIアプリの準備はOKです。

PyInstallerによるデスクトップアプリ化

ではuvでPyInstallerを追加しましょう。

uv add pyinstaller

追加したら、以下のコマンドを入力しましょう。

pyinstaller hello.py

Gradioで作成したスクリプトファイル名で実行します。

するとビルドが始まるかと思いますのでしばらく待ちます。

終了すると、distフォルダが作成され、その中にexe(macだとapp)ファイルが作成されていると思います。

ただしこのままでは動きません。

PyInstallerはどうやらGradioライブラリのパスを認識できないようなので追加してあげる必要があります。

以下の記事が参考になりました。

pyi-makespec --onefile --collect-data gradio_client --collect-data safehttpx --collect-data groovy --collect-data gradio hello.py

するとhello.specの中身が修正されると思います。

PyInstallerはカスタム設定をする際、specファイルを変更します。

次にoptimize=0の下に以下を追記します。

module_collection_mode={
        'gradio': 'py',  # Collect gradio package as source .py files
    },

またRF-DETRでも一部パスを認識できない問題がありました。

これもCursorでエラーメッセージを投げるとspecファイルを修正してくれます。

最終的なspecファイルは以下になります。

# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
import os

datas = []
datas += collect_data_files('gradio_client')
datas += collect_data_files('safehttpx')
datas += collect_data_files('groovy')
datas += collect_data_files('gradio')

# rfdetrパッケージのソースファイルを追加(TorchScriptがソースコードにアクセスするため)
try:
    import rfdetr
    rfdetr_path = os.path.dirname(rfdetr.__file__)
    datas += [(rfdetr_path, 'rfdetr')]
except ImportError:
    pass


a = Analysis(
    ['hello.py'],
    pathex=[],
    binaries=[],
    datas=datas,
    hiddenimports=[
        'rfdetr',
        'rfdetr.detr',
        'rfdetr.main',
        'rfdetr.datasets',
        'rfdetr.datasets.coco',
        'rfdetr.datasets.transforms',
        'rfdetr.util',
        'rfdetr.util.box_ops',
        'torch.jit',
        'torch.jit._script',
        'torch.jit.frontend',
        'torch._sources',
    ],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
    optimize=0,
    module_collection_mode={
        'gradio': 'py',  # Collect gradio package as source .py files
        'rfdetr': 'py',  # Collect rfdetr package as source .py files for TorchScript
    },
)
pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.datas,
    [],
    name='hello',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=True,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)

一度buildフォルダとdistフォルダを削除し、以下のコマンドでspecファイルでビルドしましょう。

pyinstaller hello.spec

distフォルダにexeファイルが生成されるので、これを起動してみましょう。

最初の立ち上がりだけやや時間がかかる印象ですが、その後はすぐ立ち上がるようになります。

配布する際はdistフォルダのみを配布すればOKです。

これでデスクトップアプリの作成は完了です。

注意事項

作成したOSでしかアプリは起動できない

Windowsで作成したものはWindowsのPCにしか配布できません。

なのでMac用のものが必要な場合はMacで作成する必要があります。

他スクリプトやデータをアプリに追加したい

PyInstallerのところでも触れましたが、基本的にspecファイルを使うと良いです。

より詳細な使い方は公式ドキュメントがおすすめです。

データ系(CSVや画像データ、重みパラメータなど)はdatasに、フォルダパスやスクリプトパスはpathexに追加すると良いです。

ですがそれでもたまに上手く認識できない時があるので、これはトライアンドエラーになります。

CursorなどのAIツールを使ったり、作成事例データを検索してみましょう。

セキュリティソフトウェアにひっかかりやすい

配布先のPCで、トロイの木馬と誤検知されてしまうことがあります。

「詳細情報」をクリックして「実行」ボタンを押せばOKです。

PyInstaller側でひと手間加えることで引っかからないようにする方法もあります。

Macの場合は別途セキュリティ設定が必要なようです。(以下Google検索AI回答文)

PyInstallerで作成したアプリをMacで開く際に、セキュリティのポップアップが表示された場合は、「システム設定」>「プライバシーとセキュリティ」で許可してください。また、Macのセキュリティを高めるために、信頼できるアプリ開発元からのアプリにのみ注意し、セキュリティソフトを導入することも検討しましょう。

Macでのセキュリティ警告を解除する方法

  1. 「システム設定」を開く:Appleメニューから「システム設定」を選択します。
  2. 「プライバシーとセキュリティ」を選択する:サイドバーから「プライバシーとセキュリティ」をクリックします。
  3. アプリを許可する:画面を下にスクロールして「セキュリティ」の項目を見つけ、横にある「開く」をクリックします。
  4. 実行を許可する:ポップアップが表示されたら、「このまま開く」をクリックします。
    • 一度許可すれば、次回から通常通りダブルクリックでアプリを開けるようになります。

こちらの動画でも紹介してありますので、ご参考ください。

まとめ

今回はuv + Gradio + PyInstallerによるAIアプリのデスクトップ化についてご紹介しました。

Pythonの環境構築ツールであるuvで環境構築し、Gradioによる軽量AIアプリと組み合わせることでPyInstallerによるデスクトップ化が容易であることを強調しました。

AI開発現場で、「使われないAI」をできるだけ減らすためにも、誰もが簡単にアプリ化できて使えるようにすることはとても重要です。

AIも技術も「使ってなんぼ」だと思っており、使われることで新たな価値創出につながります。

今回の記事が、技術やAIがより現場で実践的に使われるための参考になれば幸いです。

ここまでご覧いただきありがとうございました!

この記事を書いた人
MLエンジニア/データサイエンティスト

自動車部品メーカーで制御系ソフトウェアエンジニアを10年以上勤めた後、MLエンジニア兼データサイエンティストとしてベンチャー企業へ転職した30代男性。
個人投資家としても活動中。主に国内株式と米国株式。
2児の父であり、家事・育児に奮闘中。
自由と豊かさと技術をこよなく愛する。

よねすけをフォローする
AIプログラミング
シェアする
よねすけをフォローする
タイトルとURLをコピーしました