🆚

Gradio対Streamlit:深掘り比較(前編)

概要

Pythonで素早くWeb GUIを作るライブラリを探すとき、GradioとStreamlitは代表的な選択肢です。今回の目標はその違いを深くまで潜ること。本記事は全2回構成となります。

前編:基本のロジックとUIコンポーネント

本稿では、まず両ライブラリをざっくりと見比べて、第一印象レベルでの違いを確認します。次に、記事を読みながら手元で試したい方のために、インストール方法と実行手順を簡単にまとめます。そのうえで、具体的なUIコンポーネントごとに2つのフレームワークを比較していきます。各コンポーネントでは、最初にStreamlitでの書き方を示し、続いてGradioで同等の機能を実現する方法を紹介します。どちらの小節も冒頭に対応するサンプルコードへのリンクを置いているため、気になった部分からすぐにコードを参照できるようになっています。

後編:周辺機能とパフォーマンス

アプリの振る舞いや拡張性に関わる「周辺」のテーマに焦点を当て、認証、JS、見た目のカスタマイズ、外部サービス連携や並列処理などを扱います。
最後に、要件別にどちらを選ぶべきかを俯瞰できる比較テーブルで締めくくります。

ひと目で見るGradioとStreamlit

知らない方が初めて目にしたとき、この2つのコードベースは非常に似ています。本記事では、その相違点を詳細に検討し、異なるシナリオを表現するいくつかの異なるページを作成し、最後に2つの製品の強みと弱みを結論づけることを目的としている。

Gradio 挨拶ページの例 Streamlit 説明書からの例

最も基本的なレベルでは、どちらもスタンドアローンのウェブページを作成するための純粋なPythonによる高水準なアプローチを提供しています。Streamlitはデータ指向のダッシュボードの作成に重点を置いており、GradioはAIモデルの統合のしやすさに重点を置いています。

どちらのライブラリを使用しても、一度これらの手軽さを経験すると、HTML+JavaScriptだけでなく、簡易化したFlask+Jinjaのようなハイブリッドフレームワークに戻るのも難しく感じるでしょう。

使用目的に特化した関数以外、ページの書き方もかなり異なります。示すため後述の例の流れを説明します:Aの入力欄はユーザが埋められたらBのテキストボックスを追加し、計算結果を表示します。

一言でいうとStreamlitの考え方は命令型プログラミングの考え方です:

  1. 入力コンポーネント(A)を指定します
  2. 空文字ではないことを確認します
    1. 条件をクリアしたら出力コンポーネント(B)を追加します
    2. 新しいコンポーネントの値を設定します

逆にGradioの考え方は関数型プログラミングに似ています。

  1. 入力(A)と出力(B)のコンポーネントを指定します
    1. Aが見える、Bが見えないように設定します
  2. コンポーネントの関係性を指定します:Aが変わったら関数を実行します
    1. 関数の入力パラメーが空文字ではないことを確認します
      1. 戻り値はBの新しい値と可視の設定です

ほかのコンポーネントの流れも似ていますが、詳細は続きでお読みください。

セットアップ

両方の場合は直接Pythonでも実行できるし、Dockerの作成も簡単です。詳細の違いは以下のテーブルでまとめます。

コマンド Gradio Streamlit
実行(自動リフレッシュあり*) gradio home.py streamlit home.py
実行(自動リフレッシュなし*) python home.py .streamlit/config.toml
[server]
fileWatcherType = "none"
Docker上で自動リフレッシュ* Dockerなしと同じ .streamlit/config.toml
[server]
fileWatcherType = "poll"
デフォルトポート 7860 8501
ポート切り替え 環境変数:
GRADIO_SERVER_NAME=0.0.0.0
GRADIO_SERVER_PORT=8502
GRADIO_NUM_PORTS=1
実行コマンドのパラメータ:
streamlit run home.py --server.port=8501 --server.address=0.0.0.0
Analyticsの停止 環境変数
GRADIO_ANALYTICS_ENABLED="False"
環境変数
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
簡単なハローワールドのコード ** import gradio as gr
with gr.Blocks() as demo:
gr.Markdown("Hello World")
demo.launch()
import streamlit as st
st.text("Hello World")

*自動リフレッシュは、Pythonコードを保存した瞬間Webページもサーバーの再起動せずに新しい内容が反映することです。多くの場合はページのリフレッシュも不要です。

**streamlitはコードを実行し、そのままサーバーとしてユーザー入力を対応します。Gradioは.launch() されたコードのみを実行しないため、launc() を呼び出さない場合はコードは終了します;Dockerの場合はexited with code 0 がプリントされます。

共通注意点

  1. 両方の場合はprint() 文は使えません。代わりにloguruを勧めします。
  2. 実行中にエラーが起きたら、コントロールでメッセージを表示し、ページでエラーメッセージを表示する。直った時点でページが自動でリフレッシュされます。ただし、最初に実行したときにすでにエラーがあると実行が停止さます。

機能の比較:事例

コードを読みやすくするためインポートやユーティリティ関数を除きます。全コードは以下のリンクでダウンロードできます。

https://github.com/id-ai-factory/gradio-vs-streamlit


プロジェクトの実行はDocker Composeでお勧めします。

git clone https://github.com/id-ai-factory/gradio-vs-streamlit
cd gradio-vs-streamlit
docker compose up

以上のDocker composeファイルを実行できましたら、以下の画面が表示されます。

最初に表示されるのは総合画面です。

各実例で操作対象のファイル名が書いています。実行パスにそのファイルに切り替え、Dockerの再起動をお勧めします。

例:「テキストの入出力」の場合は対象ファイルはpages10_📄_simple_text と _10_simple_text.py ですのでdocker-compose.ymlファイルのentrypoint パラメータを以下のように切り替えます。

ページをリフレッシュしたらテキストデモのページが表示されます。

コツ:entrypoint を新しいファイルに設定して、試したいファイルをコピペで上書きするとDockerの再起動は不要になります。


実例の順序は難度と使用頻度で決定しています、すでにStreamlitかGradioに詳しい方は順番通り読まなくても問題ありません。

testに使われているデータはgradio-vs-streamlit/common にあります。

A1. テキストの入出力

テキストの入力・変換・出力は基本操作ですが、StreamlitとGradioの違いを明確に理解しやすい箇所になります。

Streamlit

ファイル: pages/10_📄_simple_text.py

通常のPythonコードを実行時のprint 文と同じで、上から下へコードが実行されます。

Pythonユーザーが新しい入力をした瞬間、Pythonのコードは再実行されますのでループなどの処理は不要になります。

text = st.text_input("テキスト入力欄:")
if text:
    st.markdown(f"処理の後はこちらになります: \n### {text.upper()}")

順番に行きますと、最初に実行で空の入力欄が表示されます。

コード側でtext は"" になります。結果としてif 文は通らずに、st.markdown は表示されません。

ユーザーが書き始まると右下に「適用ためにEnterを押してください」メッセージが出ます。

Enter押した時点でコードが再実行され、if 文を通過しmarkdownも表示されます。

if文がない場合はmarkdownの「処理の後はこちらになります:」の部分が表示されますが、textは空文字であるため空白のままで表示されます。

逆にどの場合も何かの結果が表示したいとプレースホルダーも使えます。

text = st.text_input("テキスト入力欄:")
if not text:
    text = "<空白>"
st.markdown(f"処理の後はこちらになります: \n### {text.upper()}")

Gradio

ファイル: _10_simple_text.py

Gradioの実行は「上から下へ」と多少異なります。当然Python自体はその流れですがGUIの考えはコールバック(callback)ベースです。以下の例を見てみましょう。

text_input = gr.Text(label="テキスト入力欄:")
text_output = gr.Markdown()

text_input.change(
    lambda text:f"処理の後はこちらになります: \n### {text.upper()}", 
    inputs=[text_input],
    outputs=[text_output]
 )
 
 gr.Markdown("---")

通常のPythonGUIコードまたはStreamlitでしたら、if 文が存在しないため入力と出力のコンポーネントの表示を想定しています。実際はコンポーネントの定義・準備を行っていましたが、関数名(change)通り入力が変わらない間は出力も表示されません。Markdown にパラメータを渡していないため空文字で初期化し、改行に写ります。

Streamlitと違って、キーを押した瞬間適用され、上記のlambda 関数が実行されます。

もしEnterキーで操作したい場合はsubmit_btn のパラメータを渡してください。エンター押し・ボタンクリックのイベントがsubmit となります。change 関数は今まで通り呼び出せます。

  text_input = gr.Text(label="テキスト入力欄:", submit_btn="送信")
  text_output = gr.Markdown()

  text_input.submit(lambda text:f"処理の後はこちらになります: \n### {text.upper()}", 
                     inputs=[text_input],
                     outputs=[text_output])
  
  gr.Markdown("---")

入力欄の右に側にボタンとして表示されます。BooleanのTrue に指定した場合はチャット系の矢印になります。

もう一つの違いは、一回入力した後は出力の「処理の後はこちらになります:」部分が消えません。Streamlit側で随時再実行されるため、入力がif を通過しない限り消えるからです。Gradioの場合は最初に初期化されていないの状態だったため空白として表示されますが、その後はfn関数の結果が表示されます。

単位出力の場合はchange 関数を省力できます。だいたいなコンポーネントの最初のパラメータ(fn )はinput パラメータを受ける関数をchange の代わりに使えます。関数でない場合はそのままコンポーネントの初期化に使います。

text_input = gr.Text(label="テキスト入力欄:")
text_output = gr.Markdown(
		lambda text:f"処理の後はこちらになります: \n### {text.upper()}",
		inputs=text_input
)
gr.Markdown("---")

ただし、注意点があります。change 関数と違って、ページが初期化するときに一回入力が計算されます。Streamlitと違って、text_input の戻り値、正確にlambda に渡される値は空文字ではなく、None です。結果は、ページの右上にエラーが表示されます。

エラーが表示された後、ページは通常使用できますがUXとしては問題が生じています。

ですがlambda の調整を行う事で正常動作できます。


text_input = gr.Text(label="テキスト入力欄:")
gr.Markdown(
    lambda text:f"処理の後はこちらになります: \n### {text.upper()}" if text else "",
    inputs=text_input
)
gr.Markdown("---")

lambda を書き直すことで出力に残る問題も直しました。

まとめると、change 関数とfn 引数は同じ役割で、違いは初期化になります。

重要な違いは、どっちの場合も、ユーザー入力の時、コールバックで繋がっている関数のみが再計算されいるという事です。


今まで使っていたMarkdownだと何も表示されていないように見えます。Streamlitと同じと思いますよね?

実際は違います。以下のようにMarkdown をLabel に入れ替えます。結果は、コンポーネントが表示されていないより、表示されていますが、そのエレメントは空行だったことが分かります。

Streamlitに慣れている開発者は「入力次第、表示したくない追加コンポーネントはどうする?」と思うでしょう。やり方は二つあります。
一つ目は、Streamlit流れに似ている@render 飾りを使用することです。def でコンポーネント追加はおかしいと思うかもしれませんが、下記コードを見ていただくと納得してもらえます。関数と飾りの引数を合わせたあと、中身のif 文はStreamlitの実行と同じになります:入力が消えたら、出力も消えます。

text_input = gr.Text(label="入力欄その2:")
@gr.render(inputs=text_input)
def display_result(raw_input):
    if raw_input:
        gr.Markdown(f"必ず処理されたものです: \n### {raw_input.upper()}")

ただし欠点もあります。書きかとしてコードが他の部分とスタイルが異ことによりより多少読みづらくなります。

@render でよく使えないコンポーネントがあります(【ファイル比較】の項目で例を参照)。それは使わないといけない場合以外は避けべきという事です。

では、@render なしで二つの固定マークダウンをどうすれば表示されますか?答えはvisible のパラメータです。最初にFalseに設定します。その後、fn 引数の関数で、入力され次第Trueに変えます。

def validate_input(text):
    if text:
        return gr.Markdown(f"出力は{text.upper()}" if text else "",visible=True)
    else:
        return gr.Markdown(visible=False)
[...]

text_input = gr.Text(label="入力欄その4:")
mk1 = gr.Label(fn=validate_input, inputs=text_input, visible=False)

複数のコンポーネントの時change 関数を使います。

  • Labelの場合は表示内容は毎回変わるため、validate_input_multiple の中に設定します
  • Markdown の場合は内容が固定なので、初期化値として設定し、その後はvisible の更新のみが行います
  • ほかの言語でコンポーネントのイベント関数を呼び出しているとき、コンポーネント自体がすでに入っているケースが多いです。それと違って、今回は呼び出しているコンポーネント(text_input)は明示的にパラメータとしても渡す必要があります。その必要性の例は下記です
  • text_inputを含めて複数の入力を使うケース
  • トリガーはtext_input の更新であるながら、出力関数は入力を使わないケース
  • 入力はイベントとして使って、関係ない入力欄を使うケース
def validate_input_multiple(text):
    if text:
        return [
            gr.Label(f"出力は{text.upper()}" if text else "",visible=True),
            gr.Markdown(visible=True)
        ]
    else:
        return [
            gr.Label(visible=False),
            gr.Markdown(visible=False)
        ]
[...]
text_input = gr.Text(label="入力欄その5:")
mk1 = gr.Label(visible=False)
mk2 = gr.Markdown("↑↑↑", visible=False)
text_input.change(validate_input_multiple, inputs=[text_input], outputs=[mk1, mk2])

gr.Markdown("---")

入力がない状態はmk1 もmk2 も表示されません。

入力すると【—-】の上に表示されます。


最後に、Gradioのモデル予測に特化したコンポーネントで簡単な変換を一行で出来ます。

下のコードで隠しているフラグ ボタンもあります。これはモデルを公開した後、ユーザーが不正の出力を発見したとき開発者に通知するためのボタンです。

 gr.Interface(
    lambda text1, text2:f"結果: \n### {text1.upper() + text2.upper()}",
    ["text", "text"],
    "markdown",
    flagging_mode="never")

カスタマイズ性が低い代わりに一番簡単なデモなら一番早いやり方です。

もしこの形が欲しいながらフォームより、前の即答返事がいい場合はinterface のlive パラメータをTrueに設定してください。

 gr.Interface(
    lambda text1, text2:f"結果: \n### {text1.upper() + text2.upper()}",
    ["text", "text"],
    "markdown",
    flagging_mode="never",
    live=True)

A2. 画像処理

画像の処理や表示は画像検知にも使われます。また、ユーザー向けのアプリでの使用方法はたくさんあります。

test画像はgradio-vs-streamlit/common/相談.png です。

Streamlit

ファイル:20_🖼️_image_edit.py

【ファイルアップローダ+カメラ+貼り付け】の全てを対応するコンポーネントは存在しませんので分けて実装します。

  • 画像アップロードをst.file_uplader とフィルターのパラメータで実装できます。
  • カメラはst.camera_input で実装します。
  • 貼り付けはpip install streamlit-paste-button で取得する必要があります。

三つのコンポーネントを同時に使うと色んな課題がありますので、まずはファイルアップロードのみ対応します。

画像処理のため以下ステップを行います:

  1. file_uploader はどんなファイルでもアップロードできますので、人気なファイル拡張子に絞ります。
  2. if 文でファイルアップロードが成功したかどうかを確認します。無編集の画像を表示します。
  3. この画像のフォーマットはUploadedFile になりますので、直接フィルターをかけられません。そのため以下の変換を行います。
  4. 処理済みのファイルを表示します。
image_file = st.file_uploader("Upload Image", type=["png", "jpg", "jpeg", "webp", "bmp"])

if image_file:
    st.image(image_file)
    image = Image.open(image_file)
    img_array = np.array(image)[:,:,:3]

    st.image(sepia(img_array))

結果はファイルを選択した瞬間表示されます。ボタンを使って2ステップに分けることも可能です。


ファイル:21_🖼️_image_edit_matched_simple.py

カメラとファイルアプロードを同時に使うと問題が生じます。簡単なアプローチは可能ですが、両方を同時に使用とuploader優先されためです(if文の左)。ユーザーはカメラのみもしくはファイルアップロードのみを使用すると問題ありません。

tabs = st.tabs(["Upload", "Camera"])

with tabs[0]:
    image_uploader = st.file_uploader(
		    "Upload Image", 
		    type=["png", "jpg", "jpeg", "webp", "bmp"]
		)
    if image_uploader:
        st.image(image_uploader)
        
with tabs[1]:
    image_camera = st.camera_input("Camera")

image = image_uploader or image_camera
if image:
    image_bits = Image.open(image)
    img_array = np.array(image_bits)[:,:,:3]

    st.image(sepia(img_array))
tabs = st.tabs(["Upload", "Camera"])

with tabs[0]:
    image_uploader = st.file_uploader(
		    "Upload Image", 
		    type=["png", "jpg", "jpeg", "webp", "bmp"]
		)
    if image_uploader:
        st.image(image_uploader)
        
with tabs[1]:
    image_camera = st.camera_input("Camera")

image = image_uploader or image_camera
if image:
    image_bits = Image.open(image)
    img_array = np.array(image_bits)[:,:,:3]

    st.image(sepia(img_array))

例:カメラで撮った写真を使用しファイルをアップロードすると以下の結果です。


ファイル:21_🖼️_image_edit_matched.py

改善するのは可能です。Streamlitは、現在実行されている状態しか見えませんので、「カメラ写真あり」と「アップロード写真あり」のみが見えてる状態です。そのため最後にカメラかファイル追加のどちらが使われていたと知りたいので、st.session_state を使う必要があります。session_state に前回の画像のハッシュを保存します。次のユーザーアクション・ページリフレッシュの時、現在のハッシュが異なる事が確認出来たら、それが最新と把握できます。

tabs = st.tabs(["Upload", "Camera"])

with tabs[0]:
    image_uploader = st.file_uploader("Upload Image", type=["png", "jpg", "jpeg", "webp", "bmp"])
    if image_uploader:
        st.image(image_uploader)
    uploader_hash = hash_image(image_uploader)   

with tabs[1]:
    image_camera = st.camera_input("Camera")
    camera_hash = hash_image(image_camera)

image = None
if image_uploader and image_camera:
    # ハッシュを前回の実行と比べる。
    # 異なることは新しいものがアップロードされた意味となる
    if st.session_state["upload_id"] != uploader_hash:
        image = image_uploader
    elif st.session_state["camera_id"] != camera_hash:
        image = image_camera
    elif "last_image" in st.session_state:
        # 両方あるがどっちも新しくないときは最後に使われたものが残る
        image = st.session_state["last_image"] 
    
    # 選択されたものをセッションに保存する
    st.session_state["last_image"] = image
else:
    # 片方だけだったら、それを使う
    image = image_uploader or image_camera

if image:
    image_bits = Image.open(image)
    img_array = np.array(image_bits)[:,:,:3]

    st.image(sepia(img_array))
        
# セッションにハッシュを保存する
st.session_state["upload_id"] = uploader_hash
st.session_state["camera_id"] = camera_hash

Gradio

ファイル:_20_image_edit.py

Gradioの場合はデフォルトのコンポーネントはデフォルトで【アップロート・カメラ・貼り付け】の三つが準備されています。(st.Interface、gr.Image も同じく)

その後「Interface」で簡単に結果が出力されます。

フラグを隠すためgr.Interface(..., flagging_mode="never")を使用します。

gr.Interface(sepia, "image", "image", flagging_mode="never")

カメラを使用時の注意点:「Submit」の前にカメラのアイコンを押さないと以前の状態もしくはNoneが提出されます。


ファイル:_21_image_edit_matched.py

画像のプレースホルダーが見たくない場合は【render】飾りを使って条件付きレンダリングします。そうするとStreamlitに似ているコードになります。

uploaded_image = gr.Image()

@gr.render(inputs=uploaded_image)
def show_edited_image(image):
    if image is not None:
        gr.Image(sepia(image))

入力までは入力欄のみが表示されます。

入力の後、結果も表示されます。


ファイル:_22_image_edit_matched_alternative.py

Gradioライブラリーに合わせているコードでも同じ結果を再生できます。

uploaded_image = gr.Image()
processed_image = gr.Image(visible=False)

uploaded_image.change(
    lambda image: gr.Image(sepia(image), visible=True), 
    inputs=[uploaded_image], 
    outputs=[processed_image]
)

A3. データ/行列

ユーザー・実験やあらゆるデータ・データセットの可視化のはユーザーに説明するためにも必要の上、データ処理にも便利です。

Pandasデータフレームはデータの表示スタイリング・編集・検索などもサポートしているため、ファイル読み込みに使います。

df = pd.read_csv('common/user_data.csv')
df['date_registered'] = pd.to_datetime(df['date_registered'])

Streamlit

ファイル:30_📈_data_manipulation.py

データ可視化コマンド、st.write もしくはst.dataframe で可視化できます。その二つの裏のコードは一緒です。微調整をシミュレートするため日付を日本のフォーマットに合わせ、credits_used 列を隠します。

st.dataframe(
    df,
    column_config={
        "date_registered":st.column_config.DatetimeColumn(
            format="YYYY年MM月DD日 HH:mm:ss"
        ),
        "credits_used":None
    }
) 

データ表示は現代のスタイルで、数字・日付・Booleanが自動的に適切な見せ方で可視化されます。

さらに色を付けたい場合は、制限がありながらPandasのStylerクラスで可能です。


編集は別のコンポーネントのst.data_editor でやります。パラメータは以上と一緒です。戻り値は編集されたデータフレームで、引数のデータフレームは変更されません。

df = st.data_editor(
    df,
    column_config={
        "date_registered":st.column_config.DatetimeColumn(
            format="YYYY年MM月DD日 HH:mm:ss"
        ),
        "credits_used":None
    }
) 

logger.info(f"Jdoe updated to: {df.loc[df["username"]=="jdoe"]}")

編集する度、カラムのデータ型にGUIが自動的に合わせられます:数字入力欄に文字入力が不可能、Booleanはチェックボックス操作、日付はキャレンダーから選択できます。

試しにユーザー「JDoe」のクレジットを【200】に、【active】を【False】に変えます。

編集された値をログで確認します。ページロード時に編集前の状態が表示されます。データフレームが編集された瞬間Pythonコードが再実行されるため、【適用】などは不要になります。

今までのコンポーネントのパターンを見て、今回は再実行したら、「なぜデータが失われない」と思う方もいると思います。理由はdata_editor が編集をキャッシュし、引数のデータが変わらない限り、GUIの全ての修正をデータフレームに適用させます。例えば、以上の編集の後、csvファイルを直接編集したら、次にほGUI編集の後は200とFalseは元の状態に戻ります。

Gradio

ファイル:_30_data_manipulation_show.py

データフレームの可視化はDataFrame コンポーネントで実装します。datatype は設定を行う事が可能で、日付のフォーマットなどに影響を及ぼしません。

gr.DataFrame(
    df, 
    headers=["username","email","日付","credits_used","available_credits","active"],
    datatype=["str", "str", "date", "number", "number", "bool"],
    # interactive=True
)

この状態で操作できる事は、ソートとデータ選択のみ可能になります。列の幅の調整・セル中身の一部を選択するのは不可能になります。

説明書によってinteractive=False 限定でPandasのstylerを使ってカスタマイズ可能になっていましたが私が試した所、直接header 変数を設定してstyler を使用しましたが見た目は変わりませんでした。

styler = df.style.hide(subset=["credits_used"], axis=1)\
    .relabel_index(["username","email","日付","available_credits","active"], axis=1)

with gr.Blocks() as demo:
    gr.DataFrame(
        styler, 
        interactive=False,
    )

demo.launch()

ファイル:_31_data_manipulation_edit.py

interactive パラメータを有効にすると編集も可能になります。表示と同じく、datatype が設定したとしても、入力が自由になりますので、タイプで設定したもの以外の入力も可能です。

def print_results(df):
    logger.info(f"JDoe updated to: {df.loc[df["username"]=="jdoe"]}")

with gr.Blocks() as demo:
    data_frame_interactive = gr.DataFrame(df, interactive=True)
    
    data_frame_interactive.change(
        print_results, 
        inputs=[data_frame_interactive]
    )
    
demo.launch()

データ編集は、ユーザー「jdoe」のクレジットを【200】に、【active】を【False】に変えます。

結果はログで確認します。編集が適用されるとイベントが発生するため、2段階でプリントされます。

A4. グラフ

データ分析などでグラフ・表はよく使われています。データはGPTで生成されたデータです。StreamlitもGradioも同じファイルを使います。データ可視化のときPandasがよく使われているため今回の例で同じものを使います。

df = pd.read_csv('common/chart_data.csv')

【データ/行列 】項目のコードを使ってデータを確認します。

Streamlit

ファイル:40_📊charts.py

Streamlitはもともとデータ処理やデータ可視化に特化したフレームワークです。そのため、グラフの選択肢が多い、ページ表示されるコンテントの質も高いです。さらに、Streamlitがラップしたコンポーネント以上Plotly・Altair・Vega-Liteなど第三者の可視化ツールも使用できます。


棒・線グラフ、散布図は特に前処理を行わずに可視化できます。

#%% 棒グラフ
st.bar_chart(df, x="Product Category", y="Sales")

#%% 線グラフ
df_S = df.groupby("Month", as_index=False).sum()
st.area_chart(df_S, x = "Month", y = ["Sales", "Profit"])

#%% 散布図
st.scatter_chart(df, x= "Average Price per Unit", y="Profit", size = "Sales")

カスタマイズせずでも現代的なグラフが出力されます。

GUIで表示だけではなく、移動・ズーム・画像として保存もできます。(【…】上のボタンでフルスクリーン操作できます)。

Plotlyを使用しより複雑なグラフ表示が可能なで、ユーザーの操作力も向上します。以下のコードでXYと時間の軸も使用します。

import plotly.express as px
fig = px.bar(data_frame = df,
    x= "Product Category",
    y= ["Sales", "Profit"],
    animation_frame = "Month",
    labels={
        'Product Category': "商品類",
        'Sales': '売上',
        'profit': "利益",
        'Month': "月",
        'value': "価格",
    }
)

st.plotly_chart(fig, use_container_width=True)

以下スナップショットのスクロールバーで日付を変えます。右のラベルのクリックでそのデータを隠せます。

パイチャートは基礎関数での使用が不可能のためmatplotlibを使用します。書き方はJupyterやコンソールとほぼ同様に記述でき、最後にFigure をst に渡します。

labels = df["Product Category"]
sizes = df["Units Sold"]
explode = [0]*len(sizes) 
explode[1] = 0.1
explode[8] = 0.2

fig1, ax1 = plt.subplots()
ax1.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%',
        shadow=True, startangle=90)
ax1.axis('equal') 

#%% streamlitで表示
st.pyplot(fig1)

残念ながらmatplotlib の表は操作不可能で、下記の画像見たいになります。それに加えて操作も不可能になります。


Gradio

ファイル:_40_charts.py

棒・線グラフ、散布図は特に前処理を行わずに可視化できます。

gr.BarPlot(df, x="Product Category", y="Sales")

#%% Line plot
df_melted = pd.melt(df, id_vars=['Month'], value_vars=['Profit', 'Sales'], 
                var_name='Category', value_name='Amount')
gr.LinePlot(df_melted, x="Month", y="Amount", color="Category")

#%% Scatter Plot
gr.ScatterPlot(df, x= "Average Price per Unit", y="Profit")

見た目は現代的で、色もわかりやすく自動的に選択されます。

カーサを合わせると一番近い値の詳細は表示されますがZoomやデータ選択などは不可能です。

より複雑なグラフ使用また操作を行いたい場合はPlotly、などの第三者ライブラリーをお勧めします。

fig = px.bar(data_frame = df,
    x= "Product Category",
    y= ["Sales", "Profit"],
    animation_frame = "Month",
    labels={
        'Product Category': "商品類",
        'Sales': '売上',
        'profit': "利益",
        'Month': "月",
        'value': "価格",
    }
)
# 左に寄せないとナビゲーション操作ボタンと重ねます
fig.update_layout(legend=dict(
    orientation="h",
    yanchor="bottom",
    y=1.02,
    xanchor="left",
    x=0
))

gr.Plot(fig)

ほとんどの操作は可能ですが、Streamlit、Jupyterと違い直観的で無い事があります。まずはデフォルトの設定で変数説明は右に寄せになっており、Zoomなど操作ボタンの重なりがあり、最初の実行で右の「Pan」か「Zoom」を選択しないといけませんでした。他のアニメーションメニューは1月から動作してみましたが、影響はございませんでした。

それ以外の機能は使用できます。例は、カーソルで真ん中にズームし、利益を選択します。

MatplotlibはGradioと連携も可能です。

#%% Pie chart
labels = df["Product Category"]
sizes = df["Units Sold"]
explode = [0]*len(sizes)  # only "explode" the 2nd slice (i.e. 'Hogs')
explode[1] = 0.1
explode[8] = 0.2

fig1, ax1 = plt.subplots()
ax1.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%',
        shadow=True, startangle=90)
ax1.axis('equal')  # Equal aspect ratio ensures that pie is drawn as a circle.

#%% Gradioで表示
gr.Plot(fig1)

結果は画像とほぼ同様になります。

A5.チャットボット

LLMが広がるほど、チャットボットや対話的モデルのデモが必要となります。

Streamlit

ファイル:50_🤖_chatbot.py

チャット専用で2つのコンポーネント、chat_message とchat_input があります。

chat_message はチャットのようなメッセージを表示するためものです。他のテキストと同じような固定エレメントになります。簡単なやり取りを再現しましょう。パラメータはnameとavatarのみですので右寄せや色設定は不可能です。

with st.chat_message("👾"):
    st.write("Greetings Human")

with st.chat_message("user"):
    st.write("Hello 👋")
    st.line_chart(np.random.randn(30, 3))

アイコンが被ったら分かりづらいですが、「AI」がコンテナーを持っていないではなく、背景が白だけのことです。

chat_input は形的にチャット入力ですが、基本的にtext_input と一緒です。ステートなどを
管理しないと前の入力が書き換えられてしまいます。


messages = st.container(height=400)
if prompt := st.chat_input("Say something"):
    messages.chat_message("user").write(prompt)
    messages.chat_message("👾").write(f"Let me reverse that: {prompt[::-1]}")

チャット入力がページの下に合わせるので、そのため対応は不要です。

session_state を使ってメッセージが残る機能も実装しましょう。AI連携の代わりに今までの[::-1] を使います。

msg_key = "messages" 
if msg_key not in st.session_state:
    st.session_state[msg_key] = []

messages = st.container(height=400)
if prompt := st.chat_input("Say something"):
    st.session_state[msg_key].append(("user", prompt))

    bot_reply = prompt[::-1]
    st.session_state[msg_key].append(("👾", bot_reply))

    for party, message in st.session_state[msg_key]:    
        messages.chat_message(party).write(message)

コード自体は難しくはないが結果を見ればもうちょっとカスタマイズしたいですね。

Gradio

ファイル:_50_chatbot_simple.py

すべてを含でいるChatbot のコンポーネントが公開されています。

メッセージ送信・返事・会話履歴・メッセージ削除のすべてがコンポーネントに含まれていますのでページを数行で作成することが可能です。

入力はTextboxにして、その後はユーザーがサブミットしたイベントに対応するのみです。メッセージを送るたび履歴に追加し、入力Textboxを空の状態にしたいためです。そのため出力コンポーネントが[msg, chatbot] で、戻り値は空文字と更新された履歴にします。

chatbot = gr.Chatbot(type="messages")
msg = gr.Textbox(submit_btn=True, show_label=False)

def respond(message, chat_history):
  chat_history.append({"role": "user", "content": message})
  bot_reply = message[::-1]
  chat_history.append({"role": "assistant", "content": bot_reply})
  
  return "", chat_history

msg.submit(respond, [msg, chatbot], [msg, chatbot])

直接実装していませんでしたが右上のゴミ箱アイコンでチャット履歴が消せます。

ファイル:_51_chatbot_streaming.py

現代的にチャットボットはすべて「streaming」型、返事は文字単位で返してくれる仕組みです。

それも実装しましょう。コンポーネントが優しいため、簡単にできます。

まずはリクエストの対応を2段階に分けます:

  1. ユーザーリクエストを履歴に追加し、AIに送ります(代わりにStateに保存します)
  2. then 関数で、①のイベントが終了したら返事する関数を呼び出します。これがState から1文字削って、(スリープして、)yied で部分結果の文字で更新された履歴を戻します
chatbot = gr.Chatbot(type="messages")
msg = gr.Textbox(submit_btn=True, show_label=False)
next_message = gr.State([""])

def bot_reply(history, message_buffer):
    for character in message_buffer[0]:
        history[-1]['content'] += character
        time.sleep(0.05)
        yield history

def add_user_message(message:str, chat_history:list[dict[str,str]], message_buffer):
    chat_history.append({"role": "user", "content": message})
    message_buffer[0] = message[::-1]
    chat_history.append({"role": "assistant", "content": ""})
    
    return "", chat_history

msg.submit(add_user_message, [msg, chatbot, next_message],[msg, chatbot], queue=False)\
        .then(bot_reply, [chatbot,next_message], chatbot)

アニメーションが画像で表示づらいですが返事の途中にスクリーンショットを取りました。


ファイル:_52_chatbot_tool_usage.py

ツールやRAG使用はmetadata パラメーターで実装できます。パラメータのヒントが欲しい場合はChatMessage も使用でき、結果は等しくなります。更に入力もマルチモーダルにしましょう。実際にユーザー入力で判断を行うとコードが長くなるため半固定の応答にします。

def process_file(file_path):
    # 画像の例とは違って、ファイルパスは画像オブジェクトではなく、ファイルパスになります
    image = Image.open(file_path)
    img_array = np.array(image)[:,:,:3]

    return sepia(img_array)

with gr.Blocks() as demo:
    chatbot = gr.Chatbot(type="messages", height=600)
    # マルチモーダルはファイルの追加を可能にします。数とデータタイプは引数で指定できます
    msg = gr.MultimodalTextbox(submit_btn=True, show_label=False,placeholder="Enter message or upload file")

    def respond(message:dict, chat_history:list[dict[str,str]]):
				# ファイルを繰り返し処理し、それらを履歴に追加します
				# このデモのユースケースでは、最後のファイルを保存します
        for file in message["files"]:
            chat_history.append({"role": "user", "content": {"path": (last_file := file)}})
            
        # テキストの追加. 上でも可能ですがスクリーンショットのため下にします
        if message["text"] is not None:
            chat_history.append({"role": "user", "content": message["text"]})
            
        # アシスタントのテキストも追加します
        chat_history.append({"role": "assistant", "content": "Sure thing, here you go"})
        # metadata のパラメータはツール使用コンポーネントに変換されます
        chat_history.append(
            gr.ChatMessage(
                role="assistant",
                content="Used Filter: Sepia",
                metadata={"title": "🛠️ Used tool: 🖼️ Image Processing"})
        )
        # content引数はほとんどのGradioパラメータは使用可能です
        chat_history.append({"role":"assistant", "content": gr.Image(process_file(last_file))})

        return "", chat_history

    # 送信はシンプルな例と同じです。唯一の違いは、msgオブジェクトのデータ型です
    msg.submit(respond, [msg, chatbot], [msg, chatbot])

demo.launch()

ファイルアップロードの形式はよくある型になります。

回答は想像通りユーザーの写真と処理された写真も出力されます


ファイル:_53_chatbot_actual.py

最後に実際に使用できるほどの機能を持っているファイルも紹介します。

機能は:

  1. 会話の記憶
  2. 返事ストリーミング
  3. 通信の間にユーザー入力無効化
  4. ファイル追加
    1. テキストファイルなら内容をそのまま追加(履歴にプレースホルダーを追加する)
    2. 画像ならB64で通信する
  5. チャット内容削除

今回、内容は普通と違い様々な事を同時に書かないと結果が出ませんので、途中結果は貼り付けません。

#%% インポート
import gradio as gr
from pathlib import Path
import base64
from openai import OpenAI

#%% ユーティリティ関数
def encode_image(image_path):
    """ 画像を base64 にエンコードします (OpenAI 用)"""
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode("utf-8")
    
def clear_metadata_and_options(l):
    """ 
    Gradio固有の表記をクリアします(画像認識で問題が発生します)。
		また、ファイルパスをプレースホルダに置き換えます
    """
    nl = []
    for i in l:
        nl.append({k: (v if not isinstance(v, tuple) else "<contents of a file>") for k, v in i.items() if k in ["role", "content"] })
    return nl

def analyze_image(history, prompt: str, image_path: str, extension: str):
    """OpenAI APIに画像データを送信"""

    #  まず、500エラーの原因となるタグを削除します
    history = clear_metadata_and_options(history)

    # 画像を読み込んでエンコードします
    base64_image = encode_image(image_path)

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages= [ 
            {"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {
                    "url": f"data:image/{extension};base64,{base64_image}"}
                }
            ]}
        ],
        temperature=0.5,
        stream=True,
    )
    # テストで必要であれば、コメントアウトされたセクションをstream=Falseで使用できます。
    return response 
    # return response.choices[0].message.content

def send_message(chat_history:list[dict[str,str]], rag = ""):
    """ OpenAI APIに通常のテキストメッセージを送信します。 <rag>が提供された場合、コンテキストとして追加されます。"""

    # タグは問題を引き起こしませんが、ファイルパスは削除する必要があります
    chat_history = clear_metadata_and_options(chat_history)

    # コンテキストがある場合、ヘッダーテキストとともに最後のメッセージに挿入されます
    if rag:
        chat_history = chat_history[:-1]
        chat_history[-1]["content"] += "\n\nContext/additional information:\n" + rag
    
    # 変更した履歴をAPIに送信します
    completion = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=chat_history,
        stream=True,
    )

    # テストで必要であれば、コメントアウトされたセクションをstream=Falseで使用できます。
    return completion 
    # return completion.choices[0].message.content

def complete_token(history, message_buffer):
    """ トークン単位で返信トークンをyieldします """
    if message_buffer:
        for chunk in message_buffer:
            character  = chunk.choices[0].delta.content if chunk.choices else ""
            history[-1]['content'] += character or ""
            yield history
    else:
        yield history

#%% Initialization

# コードまたはその修正版を自分のリポジトリにコミットする場合は、APIキーを削除することを忘れないでください。
# client = OpenAI(api_key="<not set>")
# または、環境変数を使用します。(OpenAIにも勧められています)
import os
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", "<not set>"))

#%% GUI 
with gr.Blocks() as demo:
    # HDディスプレーの場合は800pxは適切でしょう
    chatbot = gr.Chatbot(type="messages", height=800)
		# デフォルトの送信矢印(submit_btn)を、不要なラベル(show_label)なしで表示します
    # 標準的なプレースホルダーを追加します
    # 複数のファイルは許可されていません
    # ファイルタイプは画像に制限され、ほとんどのフォーマットは問題ないはずです。テキストはとりあえずcsvとtxtに
    msg = gr.MultimodalTextbox(submit_btn=True, show_label=False,placeholder="Enter message or upload file",
                               file_count="single", file_types=["image", ".csv", ".txt"])
    
    # トークンチャンクのジェネレータを保存する場所が必要です
    chunks = gr.State(None)

    def respond(message:dict, chat_history:list[dict[str,str]]):
        """ 応答を初期化/開始します。"""

        # ユーザーがテストを追加した場合、履歴に追加されます
        if (prompt:=message["text"]) is not None:
            chat_history.append({"role": "user", "content": prompt})

        # ファイルの種類に応じて、ファイルを読み込むか、パスを使用します
        extra_context = ""
        for file_path in message["files"]:
            chat_history.append({"role": "user", "content": {"path": (file_path)}})
            if Path(file_path).suffix in [".csv", ".txt"]:
                with open(file_path, "r") as f:
                    extra_context = f.read()
            else:
                extra_context = {"image": file_path}        
        
        # コンテキストが辞書の場合、画像であり、そうでない場合はテキストです
        # いずれの場合も、アシスタントの応答は空文字列に初期化されます
        try:
            if isinstance(extra_context, dict):
                response = analyze_image(chat_history, prompt, file_path, Path(file_path).suffix[1:])
                chat_history.append({"role": "assistant", "content": ""})
            else:
                response = send_message(chat_history, extra_context)
                chat_history.append({"role":"assistant", "content": ""})
        except Exception as ex:
            chat_history.append({"role":"assistant", "content": f"The following error occurred: {ex}"})
            response = ""

        # ユーザー入力を空文字列に設定し、新たな入力を無効にします
        return gr.MultimodalTextbox("",interactive=False), chat_history, response

    # 送信は3つの部分に分けて処理されます。
    # 1. リクエストを送信し、入力フィールドを無効にし、トークン用のジェネレータを保存し、
    #    ユーザー入力を保存し、ボットの履歴を初期化します
    # 2. チャット履歴(および表示)に、Stateオブジェクトのジェネレータからトークンを1つずつ追加します
    # 3. 入力フィールドを再び有効にします
    msg.submit(respond, [msg, chatbot], [msg, chatbot, chunks], queue=False).\
        then(complete_token, [chatbot, chunks], chatbot).\
            then(lambda:gr.MultimodalTextbox(interactive=True), outputs=msg)

if __name__ == "__main__":
    demo.launch()

コード実行ができたら以下のように機能をテストできます。

A6. ファイル内容比較

今までのページは簡単な一つのエレメントに集中するものでした。運用では相当ないと思いますのでもっと複雑で現実的なページを作りましょう。StreamlitもGradioもページ作成を簡単化しているため長いページはできるのでしょうか。

目標は4段階のページを実装することです:

  1. 同時二つアップロード
    1. 申請ボタンを押すとアップロード度
  2. 内容の確認、要約
  3. 詳細情報の表示
  4. ダウンロード

ファイルの類似度はdifflibを使って計算します。


Streamlit

ファイル:60_🔏_file_comparison.py

Streamlitで特に注意することはページの状態とレンダー回数です。順番に行きましょう。

まずはファイルアップロードを準備します。今回のコードはほぼデモにしか使えませんのでデモファイルのショートカットも準備します。片方がアップロードした瞬間残りのコードを実行したくないためst.form を使います(ユーザーが「Submit」ボタン以外はコードの実行を起こしません)。ダウンロードするときファイル名を使いたいため保存します。Streamlitで、ページが毎回リフレッシュされないためst.form を使います。フォームは中身が操作しても、st.submit_button を押さない限り実行されません。

with st.form("File comparison"):
    columns = st.columns(2)
    with columns[0]:
        file_A = st.file_uploader("File A")
        if file_A:
            filename_A = file_A.name
    with columns[1]:
        file_B = st.file_uploader("File B")
        if file_B:
            filename_B = file_B.name

    use_defualt_files = st.checkbox("Use demo files")
    submit = st.form_submit_button("実行")

次は【詳細表示】を追加します。ユーザーがファイルをアップロードしたら内容を読み込みます。もし1つのファイルのみをアップロードしたらエラーを表示します。【デモ用のファイルを使います】を選択たらアップロードしたファイルより事前準備されたファイルが優先されます。

if upload_button:
    valid_files = True
    if use_defualt_files:
        with open(filename_A := "image_edit.py") as f :
            contents_A = f.readlines()
        with open(filename_B := "image_edit_matched.py") as f :
            contents_B = f.readlines()
    elif file_A and file_B:
        contents_A = [s.decode("utf-8") for s in file_A.readlines()]
        contents_B = [s.decode("utf-8") for s in file_B.readlines()]
    else:
        st.error("Please upload both files or use the demo files checkbox")
        valid_files = False

ファイル有無確認の後、簡単な差分を表示します。

if valid_files:
    diffs = difflib.SequenceMatcher(a=contents_A, b=contents_B)
    st.text(f"The matching percentage of the files is: {diffs.ratio()*100:.2f}%")

次の段階として詳細も表示したいです。本能的なコードは以下です。

if st.button("Show full difference"):
    show_line_by_line_comparison(st, contents_A, contents_B, False)

show_line_by_line_comparisonはutils ライブラリーで書いています。結論から言うと差分タイプ(追加・削除・変更)に合わせて【色】(green/red/blue)と識別文字列(++/--/≠)を計算します。一部のサンプルが以下となります。

for diff in diffs:
		if diff[0]=='insert':
		    preface = '++'
		    color='green'
    [...]
    with st.expander(f"{idx} {preface}", expanded=True):
        diff_column = st.columns(2)
        with diff_column[0]:
            st.code(_tabulate_code(compare_to_lines[diff[1]:diff[2]]))
    [...]

ただし、Streamlitでは、【詳細を表示】ボタンを押したら、以上の比率も消えます。

原因はテキストボックスと違って、ボタンが状態を持ちません。

詳しく書くと、ページロードした瞬間で

if upload_button: => False
	if st.button("Show full difference"): => False

ファイルをアップロードした後、実行ボタンを押しますと二番目のボタンまで実行します。

if upload_button: -> True
	if st.button("Show full difference"): => False

ただし、押したら上のボタンがFalseになって、二番目のボタンがそのif 文に中にあるため、Trueだったとしても実行されません。

if upload_button: => False
	if st.button("Show full difference"): => True

これを直すため以前の例と同じようにst.session_state を使います

if upload_button or "upload_pressed" in st.session_state :
    st.session_state["upload_pressed"] = True
		    [...]
        if st.button("Show full difference") or "compare_pressed" in st.session_state:
        st.session_state["compare_pressed"] = True 

session_state のだ正しい追加方法で以下の結果を得ます。

内容をダウンロードするためボタンを準備します。この機会にStreamlitのもう一つの弱点を見てみましょう。例えば、ダウンロード前に何かのフィルターをかけたいと、今まで通りページを完全に再実行したくはありません。そのためフォームを使います。

  with st.form("Download Delta"):
      cols = st.columns(4)
      for symbol, col in zip(("++", "--", "≠", "==") ,cols):
          with col:
              st.checkbox(symbol, value=True)
      prepare_button = st.form_submit_button("Prepare download")

ただし、フォームの場合は(カスタムHTMLを除いて)直接ダウンロードさせることは不可能なので、もう一つボタンを追加します。

if prepare_button or "prepare_pressed" in st.session_state:
    st.session_state["prepare_pressed"] = True
    content = summarize_diffs(diffs, contents_A, contents_B)
    st.download_button(
        label="Download File",
        key="download_btn",
        file_name=f"diff-{filename_A}--{filename_B}.txt",
        data=content)

これですべての段階をクリアできました。それは、ページをリフレッシュせずにもう一つのファイルで実行します。今はボタンをst.session_state に入れたため、新しいファイルを入れた瞬間ページの残りすべてが表示されます。

それを防ぐには押されるボタン下部エレメントの状態すべてをリセットします。コードは以下となります。

if upload_button or "upload_pressed" in st.session_state :
    st.session_state["upload_pressed"] = True
    if upload_button:
        st.session_state["compare_pressed"] = False
        st.session_state["prepare_pressed"] = False
        
if difference_button or (
	"compare_pressed" in st.session_state and st.session_state["compare_pressed"]
	):
    if difference_button:
        st.session_state["prepare_pressed"] = False
    st.session_state["compare_pressed"] = True 

if prepare_button or ("prepare_pressed" in st.session_state and st.session_state["prepare_pressed"]):
    st.session_state["prepare_pressed"] = True

コード自体が難しくありませんがファイルが長くなるほどメンテナンスでミスを招くリスクが上がります。


Gradio

ファイル:_50_file_comparison.py

Gradioで特に注意することはコンポーネントの隠蔽と動的コンポーネント追加です。

今回はユーティリティ関数の定義などが使われるときに紹介するため、プログラムを実行したいときにできたプログラムを参照した方が分かりやすいと思います。

見た目①

最初の一歩、重ねて二つのファイルをアップロードしコンポーネントと実行のボタンを作成します。gr.File のtype パラメータで"text" に絞れますがユーザーが使いたい拡張子が漏れる可能性が高いため設定しません。

gr.Markdown("# Compare Files")
with gr.Row():
    file_output_A = gr.File(label="File A")
    file_output_B = gr.File(label="File B")

use_demo_files = gr.Checkbox(label = "Use demo files")
execute_button = gr.Button("実行")

見た目②

ファイル:`_51_file_comparison_alternative.py`

ファイルのd&dコンポーネントをどうしても使いたく**ない**場合は`gr.UploadButton` も使えますが、色々難しくなります。コンポーネントの数上がるため、対応する関数も増えます。()

```python
def upload_file(file_path):
    return [
        gr.Textbox(value=file_path),
        gr.Textbox(value=Path(file_path).name,visible=True)
    
[...]
    gr.Markdown("# Compare Files")
    with gr.Row():
        uploaded_files = []
        for _ in range(2):
            with gr.Column():
                gr.Markdown("### File A")
                upload_button = gr.UploadButton("Upload")
                file_display_name = gr.Textbox(label="Uploaded File Name",interactive=False, visible=False)
                file_fullpath = gr.Textbox(label = "Full File Path", visible=False)
                upload_button.upload(upload_file, upload_button, [file_fullpath, file_display_name])
                uploaded_files.append(file_fullpath)
        file_output_A, file_output_B = uploaded_files

    use_demo_files = gr.Checkbox(label = "Use demo files")
    execute_button = gr.Button("実行")
```

見た目はよくなっているかどうかはユーザーの判断にお任せしますが、`gr.UploadButton` のことを知ることで損はありません。

![](https://storage.googleapis.com/zenn-user-upload/522f7b312299-20251202.png)

次は実行ボタンの後のパーセント差分を表示します。コンポーネントの位置は直下です。今までの例と同じようにまずは隠蔽して(visible=False)、その後は関数で暴露します。「実行」のボタンをクリックしたときclick イベントが発生し、雇用関数が呼び出されます。

comparison_simple_result = gr.Markdown(visible=False)
show_details_button = gr.Button("Show full difference", visible=False)

file_cache = gr.State([])
execute_button.click(calculate_difference, 
                     inputs=[use_demo_files, file_output_A, file_output_B], 
                     outputs=[comparison_simple_result, show_details_button, file_cache]) 

ユーティリティ関数は両方のファイルがアップロードされているかを確認し、差分を計算します。

戻り値を使って差分のマークダウンと次のボタンをvisible=True に更新し、ファイル内容をgr.State に保存します。この変数はStreamlitのst.session_state と異なります。ページをリフレッシュしたら内容が消えます。使い道はUIコンポーネントを使わずに変数を保存できます。その必要性は次の段階で説明します。

def calculate_difference(use_demo_files, filename_A, filename_B):

    if use_demo_files:
        filename_A = "_22_image_edit_matched_alternative.py"
        filename_B = "_21_image_edit_matched.py"

    elif not (filename_A and filename_B):
        return [gr.Markdown('### <span style="color:red">Please upload both files or '+
                            'use the demo files checkbox</span>', visible=True), 
                gr.Button(),
                []
        ]
    
    contents_A = read_file_lines(filename_A)
    contents_B = read_file_lines(filename_B)
    diffs = difflib.SequenceMatcher(a=contents_A, b=contents_B)

    return [
        gr.Markdown(f"The matching percentage of the files is: {diffs.ratio()*100:.2f}%", visible=True),
        gr.Button(visible=True),
        [contents_A, contents_B]
    ]

失敗した場合のエラーメッセージはマークダウンになります。

ほとんどの場合は戻り値のコンポーネントと既に存在するコンポーネントの型を合わせないといけません。今回は例外で、st.Warning オブジェクトの使用も可能です。

elif not (filename_A and filename_B):
    return [gr.Warning("Please upload both files or use the demo files checkbox"), 
            gr.Button(),
            []
    ]

こうすると右上に警告メッセージが出ます。

両方のファイルをアップロードするか、チェックボックスでオーバーライドすると差分のまとめが出ます。

差分の詳細の段階に進みましょう。今までのものは固定の個数でした。ですので隠蔽し、その後暴露しても問題ありませんでした。今回はファイルの中に差分の個数が不明のため、それができません。change などの戻り値でのコンポーネントの数はすでにあるものと一致しないとエラーが発生されます。

ですが、以前の例で使っていた@render で対応できます。

    @gr.render(inputs=file_cache, triggers=[show_details_button.click])
    def show_edited_image(files):
        if len(files)==2:
            fa, fb = files[0], files[1]
            show_line_by_line_comparison_gradio(gr, fa, fb)

呼び出されているshow_line_by_line_comparison_gradio 関数のif/else文でGradioコンポーネントを追加します。一つ注意するところは、Streamlitと違ってコードブロックで言語の指定が必要であることです。

for diff in diffs:
		if diff[0]=='insert':
		    preface = '++'
		    color='green'
    [...]
    with gr.Accordion(f"{idx} {preface}"):
        with gr.Row():
            with gr.Column():
                if diff[0] != 'insert':
					          gr.Code(_tabulate_code([...]), language="python")
    [...]

結果のコードが予測通り表示されます。

最後の段階はダウンロードすることです。GradioはStreamlitと違って「準備」みたいなボタンは不要ですが、ここにも一つの課題があります。@render の中にDownloadButton を入れると、正しく起動しません。正しく起動するため、入力変数を変更しなければならないというものです。

その例、以下のコードは使用できません。

@gr.render(inputs=file_cache, triggers=[show_details_button.click])
def show_edited_image(files):
    if len(files)==2:
        fa, fb = files[0], files[1]
        show_line_by_line_comparison_gradio(gr, fa, fb)

        gr.Markdown("---\n### ダウンロード設定")   
        checkboxes = gr.CheckboxGroup(choices=DIFF_SYMBOLS, label = "Download options", value=DIFF_SYMBOLS)
        gr.DownloadButton(value=prepare_download_path, inputs=[file_cache, checkboxes])

その原因はDownloadButton であり他のButton と違い、value の関数をクリックするときに呼びだされず、入力のコンポーネントが更新された時点になるということです。普段は一度ページリフレッシュするときに一度入力のエレメントの初期化値で呼び出されます。ただし、これは@render の場合は行いません。そのため**gr.DownloadButtonはgr.render のなかでは使えません!**

解決手段は外に動かすことになります。ユーザーがエレメントを見える順番はミリ秒単位でズレますが大きな影響ではないと思います。

@gr.render(inputs=file_cache, triggers=[show_details_button.click])
def show_edited_image(files):
    if len(files)==2:
        fa, fb = files[0], files[1]
        show_line_by_line_comparison_gradio(gr, fa, fb)

    gr.Markdown("---\n### ダウンロード設定")

checkboxes = gr.CheckboxGroup(
		DIFF_SYMBOLS, 
		label = "Download options", 
		visible=False, 
		value=DIFF_SYMBOLS
)
download_button = gr.DownloadButton(
		value=prepare_download_path, 
		inputs=[file_cache, checkboxes], 
		visible=False
)

show_details_button.click(
		lambda:[gr.CheckboxGroup(visible=True), gr.DownloadButton(visible=True)],
		outputs=[checkboxes, download_button]
)

DownloadButton の引数は直接的なデータではなく、ファイルのパスになります。直接的なデータを渡すことは不可能です。そのためユーティリティ関数で一時ファイルを生成します。

DIFF_SYMBOLS = ["++", "--", "≠", "=="]

def prepare_download_path(contents, selected_checkboxes):
    if not contents:
        return None
    contents_A, contents_B = contents
    added, removed, replaced, unchanged = [element in selected_checkboxes for element in DIFF_SYMBOLS]
    
    diffs = difflib.SequenceMatcher(a=contents_A, b=contents_B)
    with tempfile.NamedTemporaryFile(
		    delete=False, 
		    prefix="difference_",
		    suffix=".txt"
		    ) as tmp_file:
		    
        tmp_file.write(summarize_diffs(diffs, contents_A, contents_B, added, removed, replaced, unchanged).encode('utf-8'))

        return tmp_file.name

結果は以下となります。

  • CheckboxGroup の代替

    ファイル:_51_file_comparison_alternative.py

    固まったチェックボックスの代わりに一つ一つの方が好みでしたら、それも可能です。

        @gr.render(inputs=file_cache, triggers=[show_details_button.click])
        def show_edited_image(files):
            if len(files)==2:
                fa, fb = files[0], files[1]
                show_line_by_line_comparison_gradio(gr, fa, fb)
    
            gr.Markdown("---\n### ダウンロード設定")
        
        checkboxes = []
        with gr.Row():
            for symbol in ("++", "--", "≠", "=="):
                checkboxes.append(gr.Checkbox(True, label=symbol, visible=False))
        
        download_button = gr.DownloadButton(
    		    value=prepare_download_path, 
    		    inputs=[file_cache] + checkboxes, 
    		    visible=False
    		 )
        show_details_button.click(
    		    lambda:[*[gr.Checkbox(visible=True)]*4, 
    		    gr.DownloadButton(visible=True)],
    		    outputs=[*checkboxes, download_button]
    		 )
    

    パラメータ数が変わるためprepare_download_path の関数も更新します。

    def prepare_download_path(contents, added, removed, replaced, unchanged):
        if not contents:
            return None
        contents_A, contents_B = contents
        
        diffs = difflib.SequenceMatcher(a=contents_A, b=contents_B)
        with tempfile.NamedTemporaryFile(
    		    delete=False, 
    		    prefix="difference_",
    		    suffix=".txt"
    		    ) as tmp_file:
    		    
            tmp_file.write(summarize_diffs(diffs, contents_A, contents_B, added, removed, replaced, unchanged).encode('utf-8'))
    
            return tmp_file.name
    

    この方は4列になり、一般ウェブサイトで慣れているチェックボックスのようになります。

A7. ページ埋め込み

まとめタイプのページなどは外部サイトであることが多く、また他言語を使用したフロントエンド技術を使っているためサイトを埋め込む必要があるかもしれません。

注意:試すときに同時にループになるため、60_🚪_gradio_portal と_60_streamlit_portal を同時に使用しないでください。Gradioをテストする場合は20_🖼️_image_edit など、Streamlitの場合は_50_file_comparison を使用してください。以下の例で(見た目的にインパクトがあるため)【Multi Page App】項目の_00_gradio_demo と🏠_streamlit_demo を使用します。

Streamlit

ファイル:70_🚪_gradio_portal.py

Streamlitでiframe のコンポーネントがありますので直接使用します。

import streamlit.components.v1 as components

components.iframe("http://127.0.0.1:6602", height=720, scrolling=True)
  • 自動IPアドレスの検知

    今の例はローカルDockerコンテナーを想定していますのでIPは固定でそれに合わせています。

    クラウドプロバイダーやほかの、事前にアドレスを知らない状況では動的にアドレスをとれることが必要です。ユーザーがProxy経由でアクセスしている可能性があるため外向けIPの取得が足りません。JSは必要ですのでまずはそのためのライブラリーをインストールします。

     pip install streamlit_js_eval
    

    その後ユーザーのアドレスバーに表示されているアドレスを使います。これでプロキシーがあったとしても、アドレスが変わったとしても、アドレス取得の結果に影響はありません。

    from streamlit_js_eval import get_page_location
    
    page_loc = get_page_location()
    st_url = page_loc["origin"]
    
    components.iframe(st_url, height=720, scrolling=True)
    

Iframeはボーダーなしで表示するため以下と結果になります。

問題は2点です

  1. 埋め込まれたサイトは、コンテナーを開いた後にロードが開始されるので、少しの間白く見えます。UXとしては嬉しくありませんがiframe 自体の制限なので、streamlit で直せる問題ではありません。
  2. 幅はコンテナーに合わせる事は可能ですが、高さはできませんので注意してください。ユーザの画面、ブラウザーの画面に合わせるのはJSで不可能ではありませんが難しくなります。streamlit_js_evalを使ってviewport の高さが分かったとしても、Streamlit の「Deploy …」のヘッダーが入りますし、スクロールバーがiframeの中になります。理想的にiframe の中身が変わったときにコールバックでstreamlit 側でiframeの高さを変えたいですが、不可能だと思います

Gradio

ファイル:_60_streamlit_portal.py

GradioでiFrame のコンポーネントはありませんがgr.HTML コンポーネントで簡単に実装できます。

gr.HTML('<iframe src="http://127.0.0.1:6601" title="Streamlit Portal" '+
         'style="height:720px;width:100%;"></iframe>', min_height=720)    

ポータルから見るとStreamlitのページを見えます。

iFrame に関する問題はStreamlitと同じようにあります。

  1. iFrameの中身がロードされるまで少し時間がかかります
  2. フレームのサイズはデフォルトで固定され、小さいサイズです。CSSで幅をパーセントにできますが高さは%で指定しても影響はありません(style="height:720px;width:100%;" )
  • 自動IPアドレスの検知

    ファイル:_61_streamlit_portal_dynamic_url.py

    Gradioで動的アドレスの検知は可能ですが、JSのコードを書く必要があります。HTMLエレメントに直接書くのは不可能のため、プレースホルダ/マーカ(印)として固定IDのMarkdownエレメントを作ります。実行順番のため、HTMLエレメントの編集より挿入の方は簡単で、iFrame自体は前回の例と違って直接JSで作ります。

    1. JSでiFrame のサイズなどを設定します。
    2. URLとポートを設定します。
    3. マーカーのコンポーネントを探します
    4. iFrameを挿入します
    js = """
    function createLocalhostIframe() {
        var iframe = document.createElement('iframe');
        iframe.id = 'streamlit-portal-iframe';
        iframe.style.width = '100%';
        iframe.style.height = '720px';
        iframe.style.border = 'none';
    
        var currentUrl = new URL(window.location.href);
        currentUrl.port = '6601'; 
        iframe.src = currentUrl.toString(); 
    
        var referenceElement = document.getElementById('the-iframe-goes-under-this');
    
        // Insert the iframe directly after the reference element
        if (referenceElement) {
            referenceElement.parentNode.insertBefore(iframe, referenceElement.nextSibling);
            return 'Iframe added';
        }
        return "Failed to add element -- target ID missing";
        
    }
    """
    with gr.Blocks(js=js) as demo:
        gr.Markdown("",visible=False, elem_id="the-iframe-goes-under-this")
    
    demo.launch()
    

    MPAの場合は二つの問題があります。

    • パスはトップから/streamlit_portal などに代わります。それはStreamlitのページでは存在しません。そのためcurrentUrl.pathname 、currentUrl.search 、currentUrl.hash を空文字に設定します。
    • JSを実行するため.launch の呼び出しが必要であることです。MPAの場合はインポートされたBlocksは実行されていません。そのためコードの編集が必要です。まずは関数として呼び出せるようにリファクターします
    import gradio as gr
    
    iframe_js = """
    function createLocalhostIframe() {
        var iframe = document.createElement('iframe');
        iframe.id = 'streamlit-portal-iframe';
        iframe.style.width = '100%';
        iframe.style.height = '720px';
        iframe.style.border = 'none';
    
        var currentUrl = new URL(window.location.href);
        currentUrl.port = '6601'; 
        currentUrl.pathname = ''; // In the portal this would add /streamlit-portal
        currentUrl.search = ''; // ditto
        currentUrl.hash = ''; // ditto
        iframe.src = currentUrl.toString(); 
    
        var referenceElement = document.getElementById('the-iframe-goes-under-this');
    
        // Insert the iframe directly after the reference element
        if (referenceElement) {
            referenceElement.parentNode.insertBefore(iframe, referenceElement.nextSibling);
            return 'Iframe added';
        }
        return "Failed to add element -- target ID missing";
        
    }
    """
    
    def add_iframe_block():
        gr.Markdown("",visible=False, elem_id="the-iframe-goes-under-this")
        
    
    if __name__ == "__main__":
        with gr.Blocks(js=iframe_js) as demo:
            add_iframe_block()
        demo.launch()
    

    以上に合わせてトップページも更新します

    import gradio as gr
    from _71_streamlit_portal_dynamic_url import iframe_js, add_iframe_block
    
    with gr.Blocks(js = iframe_js) as demo: #The HS is necessary for the dynamic URL
        gr.Markdown("# Gradio Demo")
        [...]
       
    with demo.route("Streamlitポータル(動的)", "/streamlit_portal_dynamic"):
        add_iframe_block()
    

A10. Multi-page App

MPAは二つの大きな目的を持っています。
(ほかのページ(1~7)を含むため「A10」のタイトルに、ファイル名も「00」の接頭辞となります)

  1. UI的に異なる機能を綺麗に分けること。
  2. コード整理・管理を容易にすること。

Streamlit

ファイル:🏠_streamlit_demo.py

多数ページアプリの実現はとても簡単ですが、カスタマイズ性は限られています。

以下の構造の通り、実行しているファイル以外のものをpages フォルダーに入れます。

ページの順番がファイル順番で判断されます。Streamlitの前処理で頭の番号は削除されるため順番設定をを使います。現代的なスタイルにするため絵文字も付けます(theme_change)は説明書の順番が違ったため番号を除きます。

通常のパラメータでpagesの上のファイルを実行します。

streamlit run streamlit_demo.py --server.port=6601 --server.address=0.0.0.0

Streamlitをインポートだけで足りますが、ホームページらしさのため数行を入れます。

# streamlit_demo.py
import streamlit as st

st.header("Streamlit Demo")

st.text("左のメニューからデモページを選択してください")
st.markdown("説明省は[このリンク](x)でアクセスできます")

左のページにリンクを張り付けるのも可能です。

st.markdown('<a href="/simple_text" target="_self">文字列入力</a>から始めましょう!',unsafe_allow_html=True)

pages の下のファイルはページの左に表示されます。

このチュートリアルの順番に合わせました。

**注意点:**複数のページデモsession_state は共通です。そのため、"button_pressed" みたいな汎用的な変数名を使わないでください。


Gradio

ファイル:_00_gradio_demo.py

マルチページアプリは執筆時点の数時間前に可能になりました(2月5日12:00JST)、説明書はまだ更新されてせんでした。もしすでにGradioがインストールされている場合はv5.15以上に更新してください。

サブページの追加方法は.lauch()されたオブジェクトのroute() です。

with gr.Blocks() as demo:
    gr.Textbox()

with demo.route("Second Page", "/second"):
    gr.Number()
    
demo.launch()

注意点:Streamlitと違って、サブページは初期化するときに読み込まれるため、一つのサブページが遅い読み込みの場合はサーバー初期化時間に影響します。詳細は【重い処理の負担】の項目をご参照ください。

最初のBlocks ページが自動的に「Home」となります。URLはトップページのままです。

その次のものは指定された名前とパスになります

Blockの中にコード実行するのは必要ですので、今までのファイルを関数型に変更してインポートするのは正しいやり方です。

注意点:一つあります:route はBlock・Interfaceなどの中に入れてはいけません

例:現在の、画像編集コードは以下です。

import gradio as gr
from common.utils import sepia

with gr.Blocks() as demo:
    gr.Interface(sepia, "image", "image", flagging_mode="never")
         
demo.launch()

Pythonでimport文を使うときに対象ライブラリーのコードが丸ごと実行されます。そのため中身のdemo.launch() も実行されます。二つのlaunch 文章を使うことになりエラーが発生します。

インポートできるようにしましょう。問題点はlaunch であるためBlock を関数に移動し、launch の呼び出しを__main__で行えるように移動します。

import gradio as gr
from common.utils import sepia

def image_edit_page():
    with gr.Blocks() as demo:
        gr.Interface(sepia, "image", "image", flagging_mode="never")
    return demo
         
if __name__ == "__main__":
    image_edit_page().launch()

…ですがPythonのimportに裏技があるので必要な編集の量を減らせます。すべてのソースコードを関数に移動は必要なくなります。

import gradio as gr
from common.utils import sepia

with gr.Blocks() as demo:
    gr.Interface(sepia, "image", "image", flagging_mode="never")
     
if __name__ == "__main__":
    demo.launch()

最後にホームページもまとめます。


with gr.Blocks() as demo:
    gr.Markdown("# Streamlit Demo")

    gr.Markdown("左のメニューからデモページを選択してください")
    gr.Markdown("説明省は[このリンク](x)でアクセスできます")

    gr.Markdown('<a href="/simple_text" target="_self">文字列入力</a>から始めましょう!')

with demo.route("文字列入出力", "/simple_text"):
    import _10_simple_text

with demo.route("画像編集", "/image_edit"):
    import _20_image_edit

with demo.route("画像編集(合わせ)", "/image_edit_matched"):
    import _21_image_edit_matched
[...]

5.15のバージョンはroute の適用にはサーバー再起動が必要です。

今まで書いていたコードの機能に影響はありません

この先

今回の記事では、Gradio と Streamlit の基本機能と GUI エレメントの使い方を比較しました。
次の記事では、以下の補助的な機能に深堀りします。

比較(両フレームワークで可能)

  • 情報処理が行われるタイミング(サーバー起動vsユーザーのリロード時)
  • リアルタイム情報の表示(コンポーネントの自動リフレッシュ)
  • JavaScriptの埋め込み
  • ユーザー認証(シンプルとOAuth)
  • 色やほかのほスタイルのカスタマイズ

独自機能(片方のみ)

  • FastAPIとの連携
  • HuggingFace連携
  • Progressive Web App (PWA) 対応
  • データ処理の並列化

最後に、どちらのフレームワークを選ぶか迷っている方に向けて、判断の助けとなる参照テーブルも用意する予定です。

株式会社インフォメーション・ディベロプメント

Discussion