顯示具有 gradio 標籤的文章。 顯示所有文章
顯示具有 gradio 標籤的文章。 顯示所有文章

2025年6月28日 星期六

Python 學習筆記 : 簡單好用的 Web app 套件 gradio (十)

最近幾天在測試 OpenAI Image API 時因為要用到兩個連動的 gr.Radio 元件, 學到了 gr.update() 函式的用法, 因此對此函式用法做了一番測試並紀錄如下.

本系列全部文章索引參考 :


函式 gr.update() 用來更新元件的屬性, 用來在互動式應用中動態更新 Gradio 使用者介面元件的屬性 (不是重建整個元件), 例如點擊按鈕, 改變選單之選項, 元件預設值, 顯示或隱藏元件等等, 無需重新載入整個介面, 其參數結構如下 : 

gr.update(
    value=None,                # 更新元件的值(例如文字、選取項目等)
    choices=None,             # 更新選單元件的選項(例如 Dropdown, Radio 等)
    visible=True,               # 是否顯示元件(True=顯示, False=隱藏)
    interactive=True,         # 是否可互動(False=禁用元件)
    label=None,                 # 更新元件的標籤文字
    info=None,                  # 更新元件下方的說明文字
    elem_id=None,             # 更新 HTML 元素 ID
    elem_classes=None,     # 更新 HTML class 名稱(list of str)
    container=None,           # 是否包裝在容器中(通常用不到)
    scale=None,                  # 控制元件所佔水平空間比例
    min_width=None,        # 設定元件最小寬度(px)
    render=True                 # 是否渲染此變更(預設 True)
    )

參數說明如下表 :


參數名稱 說明
value 更新元件的值,例如文字內容、選取項目或輸入數字等
choices 適用於 Dropdown、Radio 等元件,用來更新選項列表
visible 是否顯示元件,True 為顯示,False 為隱藏
interactive 是否可互動,False 時會讓元件變成唯讀或無法點擊
label 更新元件的標籤文字
info 更新元件下方顯示的說明資訊
elem_id 更新元件的 HTML 元素 ID,可用於自訂樣式或 JS 操作
elem_classes 更新 HTML class 名稱,可為字串或字串列表,用於自訂樣式
container 是否顯示外層容器,通常保留預設值即可
scale 控制元件在橫向空間中所佔比例,用於區塊佈局(0~1)
min_width 設定元件最小寬度(像素),超過該寬度才會換行
render 是否立即渲染更新,預設為 True


下列 UI 元件之事件方法常用來觸發呼叫 gr.update() 函式去動態更新元件之屬性 :


常用事件方法 說明
click() 按下按鈕時觸發,常用於 gr.Button
change() 當元件值改變時觸發,例如 Dropdown、Radio、Slider 等
submit() 當使用者在 Textbox 按 Enter 鍵時觸發
blur() 當元件失去焦點(如離開輸入框)時觸發
input() 使用者輸入時即時觸發(如每輸入一字)
release() 滑鼠釋放時觸發,常用於 Slider 或繪圖板元件


最常用的是按鈕元件的 click() 或 submit() 方法與選項元件 (Radio, Checkbox, Dropdown) 勾選動作觸發的 change() 方法. 最重要的一點是, UI 元件必須放在 gr.Blocks 語境內才能動態更新, gr.Interface 只處理輸入到輸出的靜態網頁, 無法做元件屬性變更. 

例如 : 


測試 1 : 按鈕的 click 事件更新文字框之顯示或隱藏狀態 (1) [看原始碼]

# gradio-update-test-2.py
import gradio as gr

def show_textbox():
    return gr.update(visible=True)  # 傳回 Update 物件

def hide_textbox():
    return gr.update(visible=False)  # 傳回 Update 物件

with gr.Blocks() as blocks:
    show_btn=gr.Button('顯示輸入框')
    hide_btn=gr.Button('隱藏輸入框')
    textbox=gr.Textbox(label='輸入框', visible=False)  # 文字框預設為隱藏
    show_btn.click(fn=show_textbox, inputs=[], outputs=textbox)
    hide_btn.click(fn=hide_textbox, inputs=[], outputs=textbox)
blocks.launch()

此例用兩個按鈕 show_btn 與 hide_btn 分別用來控制文字框的顯示與隱藏 (文字框預設為隱藏), 當按下這兩個按鈕時會分別呼叫 show_textbox() 與 hide_text(), 這兩個函式都是呼叫 gr.update() 後將結果傳回給 outputs 所指定的元件 (即 show_textbox 與 hide_textbox 文字框), gr.update() 會傳回一個攜帶指定屬性 visible 的 Update 物件, 文字框收到此 Update 物件後就會根據此 Update 物件之屬性去修改自己的屬性 (即 visible=True/False), 所以 Update 物件相當於一個 modify 指令. 注意, 此處因為 click 事件的處理函式不需要輸入元件的資訊, 因此 inputs 傳入空串列即可. 結果如下 : 

按上面按鈕顯示輸入框 :




按下面按鈕隱藏輸入框 :




其實可以只用一個按鈕達成同樣功能, 例如 : 


測試 2 : 按鈕的 click 事件更新文字框之顯示或隱藏狀態 (2) [看原始碼]

# gradio-update-test-2.py
import gradio as gr

def handler(showing):
    new_state=not showing  # 狀態交替:True → False、False → True
    return (  # 根據新狀態回傳更新指令 (傳回 Update 與 State 物件)
        gr.update(visible=new_state),  # 更新 textbox 是否顯示
        gr.update(value='隱藏文字框' if new_state else '顯示文字框'),  # 更新按鈕文字
        new_state  # 更新狀態紀錄
        )

with gr.Blocks() as blocks:
    state=gr.State(False)  # 初始狀態為 False(隱藏文字框)
    btn=gr.Button('顯示文字框')
    textbox=gr.Textbox(label='輸入框', visible=False)  # 文字框預設隱藏
    # outputs: textbox, btn, state (順序對應 handler 回傳值)
    btn.click(fn=handler, inputs=state, outputs=[textbox, btn, state])
blocks.launch()

此例僅用一個按鈕 btn 來控制輸入框之顯示, 並使用 State 物件紀錄文字框的狀態, 當按下按鈕時將此物件傳給 inputs 參數, 讓 handler() 依據此 State 物件來交替狀態, 注意, handler() 的傳回值 (兩個 Update 物件與一個 State 物件) 順序必須對應 click() 的 outputs 參數串列之順序. 當 outputs 所列的輸出元件收到這三個物件後會根據物件內容更新自己的屬性值 (True/False), 結果如下 :




也可以用 gr.Checkbox 元件來控制輸入框顯示與否, 例如 :


測試 3 : 選項元件的 change 事件更新文字框之顯示或隱藏狀態 (2) [看原始碼]

# gradio-update-test-3.py
import gradio as gr

def handler(showing):  # 傳入輸入元件之值 True/False
    return gr.update(visible=showing)  # 傳回 Update 物件

with gr.Blocks() as blocks:
    checkbox=gr.Checkbox(label='顯示輸入框')
    textbox=gr.Textbox(label='輸入框', visible=False)  # 預設隱藏
    checkbox.change(fn=handler, inputs=checkbox, outputs=textbox)
blocks.launch()

此例利用 gr.Checkbox 元件有無勾選來控制文字輸入框之顯示與否, 勾選動作會觸發 change 事件呼叫 handler() 函式來處理, 傳入參數 inputs 就是這個 Checkbox 物件, change() 會先抽取此物件之值 (True/False) 再傳給 handler(), 因此 handler() 接收到的 showing 參數是 True/False, 它會傳回一個攜帶 visible 屬性的 Update 物件, 傳回給 outputs 所指的輸入框物件 textbox 後即依據此屬性更改輸入框的 visible 狀態, 結果如下 :




Checjbox 預設不勾選, 輸入框是隱藏的; 勾選後就會顯示輸入框了. 

2025年6月26日 星期四

OpenAI API 學習筆記 : 呼叫 Image API 生圖 (二)

在前一篇測試中已對 OpenAI Image API 做過初步測試, 本篇要使用 Gradio 做為呼叫 Image API 生圖的使用者介面. 

本系列全部測試筆記參考 :


關於 Gradio 套件用法參考 : 


下面範例以直觀的作法處理模型與尺寸選擇器 : 


測試 1 : 模型與尺寸選擇器不連動的生圖介面 [看原始碼]

# gradio-openai-image-api-test-1.py
import gradio as gr
from openai import OpenAI

def generate_images(prompt, api_key, **kwargs):
    client=OpenAI(api_key=api_key)
    replies=client.images.generate(prompt=prompt, **kwargs)
    urls=[item.url for item in replies.data]
    return urls

def handler(api_key, prompt, model_sel, size_sel, image_count):
    if not api_key.strip():  # 處理使用者未輸入金鑰問題
        return [], '請輸入 OpenAI API Key'
    elif not prompt.strip():  # 處理使用者未輸入提示詞問題
        return [], '請輸入提示詞 (prompt)'
    if model_sel == 'dall-e-2':  
        n=image_count  # dall-e-2 允許生 1~10 張圖
        if size_sel == 'dall-e-2:256x256|dall-e-3:1024x1024':
            size='256x256'
        elif size_sel == 'dall-e-2:512x512|dall-e-3:1024x1792':
            size='512x512'
        else:
            size='1024x1024'
    else:  # model_sel='dall-e-3'
        n=1  # dall-e-3 只允許生 1 張圖
        if size_sel == 'dall-e-2:256x256|dall-e-3:1024x1024':
            size='1024x1024'
        elif size_sel == 'dall-e-2:512x512|dall-e-3:1024x1792':
            size='1024x1792'
        else:
            size='1792x1024'
    urls=generate_images(prompt, api_key, model=model_sel, n=n, size=size)
    msg=f'model:{model_sel}\nsize:{size}\nn:{n}'  # 輸出設定值
    return urls, msg  # 傳回生圖之網址給 Gallery, 設定值給狀態訊息

api_key=gr.Textbox(label='請輸入金鑰 (API key)', type='password')
prompt=gr.TextArea(label='請輸入提示詞 (Prompt)', max_lines=10)
model_sel=gr.Radio(
    label='選擇模型',
    choices=['dall-e-2', 'dall-e-3'],
    value='dall-e-3'
    )
size_sel=gr.Radio(
    label='選擇圖片尺寸',
    choices=[
        'dall-e-2:256x256|dall-e-3:1024x1024',
        'dall-e-2:512x512|dall-e-3:1024x1792',
        'dall-e-2:1024x1024|dall-e-3:1792x1024'
        ],
    value='dall-e-2:256x256|dall-e-3:1024x1024'
    )
image_count=gr.Slider(
    label='圖片張數',
    minimum=1,
    maximum=10,
    step=1,
    value=1
    )
iface=gr.Interface(
    fn=handler,
    inputs=[api_key, prompt, model_sel, size_sel, image_count],  
    outputs=[
        gr.Gallery(label='生成的圖片'),
        gr.Textbox(label='狀態訊息', interactive=False)
        ],
    title='OpenAI Image API 測試',
    flagging_mode='never'
    )
iface.launch()

此例使用兩個 Radio 單選圓鈕來選擇生圖模型與圖片尺寸, 因為 dall-e-2 與 dall-e-3 分別有三種不同尺寸, 故使用 if else 判斷 model_sel 與 size_sel 來決定 size 之值. 為了避免使用者未輸入金鑰或提示詞, 在 handler() 中一開始便檢查這兩個欄位是否為空字串, 是的話就終止生圖並於狀態資訊欄顯示原因. 

輸入前一篇測試使用的描繪亞洲女性臉孔的提示詞 (使用中文版) :

美麗的亞洲女性,優雅且高貴,柔和的自然光,細緻的五官,富有表情的雙眼配上自然的睫毛,光滑如瓷的肌膚,精緻的骨骼結構,自然的妝容,黑色亮麗的頭髮帶有柔和的波浪,神情寧靜,寫實風格,高解析度,專業人像攝影,淺景深,溫暖的色調,電影感燈光,傑作級品質,極致細緻。

使用 dall-e-3 模型繪製 1024x1024 一張結果如下 :




使用 dall-e-2 模型繪製 256x256| 兩張結果如下 :




使用 dall-e-2 模型繪製 512x512| 四張結果如下 :




此 web app 已發布於 Hugging Face Spaces :



上面範例中的模型選單與尺寸選單是獨立不連動的, 我們是利用 if else 依據模型選單值來決定尺寸選單要用哪個選項值, 這種做法簡單但介面不太友善, 使用者看到尺寸選單時會有點困惑. 比較友善的做法是要讓這兩個選單連動, 讓尺寸選單依據模型選單的值顯示 dall-e-2 或 dall-e-3 尺寸.

因為 gr.Interface 不支援元件間連動, 必須使用 gr.Blocks() 結構才能動態更新元件 (例如此處的 gr.Radio) 內容, 將上面範例改寫為如下 gr.Blocks() 架構的版本 : 


測試 2 : 模型與尺寸選擇器連動的生圖介面 [看原始碼]

# gradio-openai-image-api-test-2.py
import gradio as gr
from openai import OpenAI

# 圖片尺寸選項
size_options={
    'dall-e-2': ['256x256', '512x512', '1024x1024'],
    'dall-e-3': ['1024x1024', '1024x1792', '1792x1024']
    }

# 呼叫 OpenAI API 生圖
def generate_images(prompt, api_key, **kwargs):
    client=OpenAI(api_key=api_key)
    replies=client.images.generate(prompt=prompt, **kwargs)
    urls=[item.url for item in replies.data]
    return urls  # Gallery 只能接收串列

# 執行按鈕時處理函式
def handler(api_key, prompt, model_sel, size_sel, image_count):
    if not api_key.strip():   # 處理使用者未輸入金鑰問題
        return [], '請輸入 OpenAI API Key'
    if not prompt.strip():   # 處理使用者未輸入提示詞問題
        return [], '請輸入提示詞 (prompt)'
    if model_sel == 'dall-e-2':
        size=size_sel
        n=image_count   # dall-e-2 允許生 1~10 張圖
    else:
        size=size_sel
        n=1   # dall-e-3 只允許生 1 張圖
    urls=generate_images(prompt, api_key, model=model_sel, n=n, size=size)
    msg=f'模型: {model_sel}\n尺寸: {size}\n張數: {n}'   # 輸出設定值
    return urls, msg  # 傳回生圖之網址串列給 Gallery, 設定值給狀態訊息

# 模型選單連動圖片尺寸選單 : 動態更新圖片尺寸選單
def update_size_options(selected_model):
    return gr.update(
        choices=size_options[selected_model],
        value=size_options[selected_model][0]
        )

# 使用 Blocks 語法 (才有元件連動功能)
with gr.Blocks(title='OpenAI Image API 測試') as blocks:
    gr.Markdown('## 🎨 使用 DALL·E 2 / 3 生圖')
    api_key=gr.Textbox(label='請輸入金鑰 (API key)', type='password')
    prompt=gr.TextArea(label='請輸入提示詞 (Prompt)', max_lines=10)
    model_sel=gr.Radio(
        label='選擇模型',
        choices=['dall-e-2', 'dall-e-3'],
        value='dall-e-3'
        )
    size_sel=gr.Radio(
        label='選擇圖片尺寸',
        choices=size_options['dall-e-3'],
        value=size_options['dall-e-3'][0]
        )
    image_count=gr.Slider(
        label='圖片張數 (DALL·E 2 專用)',
        minimum=1,
        maximum=10,
        step=1,
        value=1
        )
    submit_btn=gr.Button('開始生成')
    gallery=gr.Gallery(label='生成的圖片')
    status=gr.Textbox(label='狀態訊息', interactive=False) # 僅輸出
    # 連動更新圖片尺寸選單
    model_sel.change(
        fn=update_size_options,  # 呼叫自訂函式更新尺寸選單內容
        inputs=model_sel,  # 模型選單值
        outputs=size_sel   # 尺寸選單值
        )
    # 點擊按鈕觸發 handler
    submit_btn.click(
        fn=handler,
        inputs=[api_key, prompt, model_sel, size_sel, image_count],
        outputs=[gallery, status]
        )
blocks.launch()

執行後模型與尺寸選單就會連動了, 預設是 dall-e-3 選單 : 




模型改選 dall-e-2 時尺寸選單會更新選項 :




再次生成亞洲女性臉孔 (使用英文提示詞) : 




選擇 dall-e-3 模型生成一張 1024x1024 圖片 :




選擇 dall-e-2 模型生成兩張 1024x1024 圖片 :





此 Gradio 應用程式我已發佈到 Hugging Face Spaces 平台 :


2025年2月27日 星期四

Python 學習筆記 : 使用 DuckDuckGo API 搜尋網路資料 (三)

在前面的測試中, 我們發現 DuckDuckGo 物件的 chat() 背後會串接 OpenAI 的語言模型 (預設使用 gpt-4o-mini 模型). 本篇要使用 Gradio 的 Chatbot 元件作為 UI 介面來實作 AI 聊天機器人. 

本系列之前的文章參考 :


 
9. 使用 Gradio 的 Chatbot 製作 DuckDuckGo 聊天機器人 : 

Gradio 的 Chatbot 元件會用聊天泡泡形式呈現對話過程, 是專為聊天機器人之類的應用而設計的 UI 元件, 作法參考下面這篇串接 OpenAI API 的聊天機器人 :


本測試使用 Chatbot 元件透過 DuckDuckGo 物件的 chat() 方法來與 gpt-3.5-turbo 或 gpt-4-flash 模型聊天, 與上面串接 OpenAI API 不同之處是 chat() 會自動記憶之前的聊天歷史, 因此不需把對話歷史作為 prompt 傳送給模型, 程式碼如下 :

# duckduckgo_chatbot_1.py
import gradio as gr
from duckduckgo_search import DDGS

def ask_duckduckgo(prompt):
    global chat_history
    chat_history.append({'role': 'user', 'content': prompt})
    bot_response=ddgs.chat(prompt)
    chat_history.append({'role': 'assistant', 'content': bot_response})
    return chat_history

ddgs=DDGS()
chat_history=[]
prompt=gr.Textbox(label='您的詢問: ')
chatbot=gr.Chatbot(type='messages',
                   height=400,
                   placeholder='我們的對話',
                   show_copy_button=True)
iface=gr.Interface(
    fn=ask_duckduckgo,
    inputs=prompt,  
    outputs=chatbot,
    title='DuckDuckGo 聊天機器人',
    flagging_mode='never',
    )
iface.launch()

此例設置了一個 Textbox 輸入元件 prompt 來輸入提示詞, 以及一個 Chatbot 元件來輸出對話歷史, 並以一個串列 chat_history 來記錄對話歷史. 當輸入提示詞, 按下 Submit 按鈕時會呼叫 fn 所指的處理函式 ask_duckduckgo(), 將 prompt 傳給 DDGS 物件的 chat() 方法即可取得回應, 結果如下 : 






可見即使沒有將之前的對話歷史連同 prompt 傳給 chat(), 它依然能記得之前說了甚麼. 

但是上面的聊天機器人使用全域變數 chat_history 來儲存對話紀錄並不安全, 較好的做法是使用 Blocks 元件自行排版以建立一個語境, 然後在裡面建立一個 State 物件, 它會自動儲存 Chatbot 元件內的對話歷史. 

修改後的程式如下 :

# duckduckgo_chatbot_2.py
import gradio as gr
from duckduckgo_search import DDGS

def ask_duckduckgo(prompt, history):
    history.append({'role': 'user', 'content': prompt})
    try:
        bot_response=ddgs.chat(prompt) 
        history.append({'role': 'assistant', 'content': bot_response})
    except Exception as e:
        history.append({'role': 'assistant', 'content': f'發生錯誤:{str(e)}'})
    return history, ""

ddgs=DDGS()
with gr.Blocks(title='DuckDuckGo 聊天機器人') as blocks:
    chatbot=gr.Chatbot(type='messages',
                       height=400,
                       placeholder='我們的對話',
                       show_copy_button=True)
    state=gr.State([])  # 建立狀態物件來記錄對話歷史
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")    
    send_btn.click(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
    prompt.submit(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
blocks.launch()

此例做了如下變動 : 
  • 呼叫 chat() 的程式碼被放入 try except 結構內以避免出現例外時 app 崩潰.
  • ask_duckduckgo() 傳回值增加一個空字串元素用來清空提示詞輸入框以便能直接輸入下一個提示詞, 這對應到 click() 方法 outputs 參數的第二元素 prompt. 
  • 增加 prompt.submit() 以便能在提示詞輸入框按下 Enter 時效果與按下送出鈕一樣. 
結果如下 : 




上面的程式仍有不足之處, 例如無法清除聊天紀錄重新開始, 我們可以在版面中增加一個 "清除歷史" 的按鈕來達成, 程式碼修改如下 :

# duckduckgo_chatbot_3.py
import gradio as gr
from duckduckgo_search import DDGS

def clear_history():
    return [], []  # 同時清除 chatbot 和 state

def ask_duckduckgo(prompt, history):
    history.append({'role': 'user', 'content': prompt})
    try:
        bot_response=ddgs.chat(prompt) 
        history.append({'role': 'assistant', 'content': bot_response})
    except Exception as e:
        history.append({'role': 'assistant', 'content': f'發生錯誤:{str(e)}'})
    return history, ""

ddgs=DDGS()
with gr.Blocks(title='DuckDuckGo 聊天機器人') as blocks:
    chatbot=gr.Chatbot(type='messages',
                       height=400,
                       placeholder='我們的對話',
                       show_copy_button=True)
    state=gr.State([])
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")
        clear_btn=gr.Button("清除對話")
    send_btn.click(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
    prompt.submit(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
    clear_btn.click(clear_history, inputs=None, outputs=[chatbot, state])
blocks.launch()

此例增加了一個 clear_btn 按鈕, 它的輸出有兩個 : chatbot 與 state 物件, 當按下此按鈕時會呼叫 clear_history() 函式, 它會傳回兩個空串列分別清除 chatbot 與 state 物件的內容, 這樣不僅是聊天泡泡內容全部被清除, 連內部儲存的對話歷史狀態也被清除, 結果如下 : 




但是當我按下 "清除歷史" 再次詢問我養的貓咪, 它居然還記得! 這是因為 DuckDuckGo 本身的記憶還在的緣故, 解決之道是要把 DDGS 物件重新起始, 也就是重新建立一個 DDGS 物件, 修改後的程式碼如下 :

# duckduckgo_chatbot_3.py
import gradio as gr
from duckduckgo_search import DDGS

def clear_history():
    global ddgs       # 取用全域變數
    ddgs=DDGS()   # 重新初始化
    return [], []  # 同時清除 chatbot 和 state

def ask_duckduckgo(prompt, history):
    history.append({'role': 'user', 'content': prompt})
    try:
        bot_response=ddgs.chat(prompt) 
        history.append({'role': 'assistant', 'content': bot_response})
    except Exception as e:
        history.append({'role': 'assistant', 'content': f'發生錯誤:{str(e)}'})
    return history, ""

ddgs=DDGS()
with gr.Blocks(title='DuckDuckGo 聊天機器人') as blocks:
    gr.Markdown("# DuckDuckGo 聊天機器人")
    chatbot=gr.Chatbot(type='messages',
                       height=400,
                       placeholder='我們的對話',
                       show_copy_button=True)
    state=gr.State([])
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")
        clear_btn=gr.Button("清除對話")
    send_btn.click(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
    prompt.submit(ask_duckduckgo, inputs=[prompt, state], outputs=[chatbot, prompt])
    clear_btn.click(clear_history, inputs=None, outputs=[chatbot, state])
blocks.launch()

此例主要的變動是在 clear_history() 中利用 global 取用全域變數 ddgs, 然後重新初始化一個 DDGS 物件給它, 這樣就可以重設 DuckDuckGo API 內儲存之歷史對話紀錄了. 另外使用 gr.Markdown() 在 Web app 開頭添加標題. 

首先送出提示詞 '我有養兩隻貓, 名叫小咪與萬萬' 後按下清除歷史鈕, 詢問 '你還記得我養了幾隻貓嗎? 名字是甚麼?' 顯示結果如下 : 




這樣果然就順利清除歷史對話了. 

我把最後一個範例程式佈署在 HuggingFace Space 上 : 


PS : 為了在手機螢幕能看到送出鈕, 我將 Chatbot 元件高度改為 320px.

2025年2月25日 星期二

Python 學習筆記 : 簡單好用的 Web app 套件 gradio (九)

最近因為測試 DuckDuckGo 搜尋引擎的 API, 回頭找 Gradio 教學文件才發現它還有一個專門用在開發聊天機器人的好物 : Chatbot 類別, 今天就來測試看看吧! 

本系列文章索引參考 : 



十二. 使用 Chatbot 元件製作聊天機器人 :    

Chatbot 類別是 Gradio 專為對話式應用設計的 UI 元件, 它會以類似聊天泡泡的形式呈現使用者與聊天機器人之間的互動紀錄 (支援 Markdown 語法), 但它只是呈現聊天輸出, 仍需搭配 Textbox 與 Button 等輸入 UI 元件才能實現完整的聊天介面. 


1. 建立 Chatbot 物件 : 

呼叫 Chatbot() 建構式並傳入參數即可建立 Chatbot 物件 : 

chatbot=gr.Chatbot(type='messages', height=400, placeholder='我們的對話')

Chatbot() 建構式常用參數如下表 : 


Chatbot() 常用參數 說明
type value 的類型 : "tuples" (舊版串列對格式, 預設) 或 "messages" (新版)
value 初始對話歷史, 舊版格式為二維串列對, 新版為 role 與 content 鍵之字典串列 
height 聊天視窗高度, 可以是整數 (單位 px) 或字串 (如 "400px")
placeholder 對話歷史為 None 時顯示的文字
label 聊天視窗上方的標籤文字
bubble_full_width 聊天泡泡是否佔據整個寬度, 預設 True
avatar_images 使用者與機器人的頭像 (tuple), 格式 (user_avatar, bot_avatar)
render_markdown 是否將對話內容渲染為 Markdown 格式, 預設 True
show_copy_button 是否在聊天泡泡旁顯示「複製」按鈕, 預設 False
visible 元件是否可見, 預設 True
scale 在佈局中相對於其他元件的縮放比例, 類型為 int, 預設 0
min_width 元件的最小寬度 (單位 px), 類型為 int


注意, type 參數預設值為 "tuples", 表示聊天歷史 value 參數值的格式是二維串列對 : 

[[user_message, bot_response], ...]  

例如 : 

 [["嗨", "你好"], ["你是誰", "我是 AI 助理"], ...]

但新版 Gradio 已經改用 type="messages", 表示 value 參數要使用如下字典串列格式 : 

[{"role": "user", "content": "提示詞1"}, {"role": "assistant", "content": "AI 回覆1"}, ... ]

但為了向下支援, type 預設值仍然是 "tuples", 因此使用新版的 Chatbot 時 type 參數務必設定為 "messages", 例如 : 

[{"role": "user", "content": "嗨"}, {"role": "assistant", "content": "你好"}]

下面是一個鸚鵡聊天機器人的範例, 它會將收到的訊息直接回應給詢問者 : 


import gradio as gr

def handler(in1):
    global chat_history
    chat_history.append({'role': 'user', 'content': in1})
    bot_response=in1  # 鸚鵡回應
    chat_history.append({'role': 'assistant', 'content': bot_response})
    return chat_history

chat_history=[]  # 儲存對話歷史
in1=gr.Textbox(placeholder='輸入你的訊息 ...')
out1=gr.Chatbot(type='messages', height=400, placeholder='我們的對話')
iface=gr.Interface(
    fn=handler,
    inputs=in1,
    outputs=out1,
    title='鸚鵡聊天機器人',
    flagging_mode='never'  
    )
iface.launch()

此例使用一個串列 chat_history 來儲存對話歷史紀錄, 其元素格式為以 role 與 content 為鍵的字典序列 (新版 Gradio Chatbot 格式), 處理函式 handler 會將詢問者的輸入訊息直接放入 content 鍵中存入歷史對話串列中後傳回給輸出元件 Chatbot 物件, 結果如下 : 




此例只是用來說明 Chatbot 用法的偽聊天機器人, 它只會鸚鵡學語而已. 參考下面範例改成串接 OpenAI API 與大語言模型聊天, 但用的是 Textbox 元件 :


下面範例則是改用 Chatbot : 

import gradio as gr
from openai import OpenAI

def ask_gpt(api_key, prompt):
    global chat_history
    chat_history.append({'role': 'user', 'content': prompt})
    client=OpenAI(api_key=api_key)    
    chat_completion=client.chat.completions.create(
        messages=chat_history,
        model='gpt-3.5-turbo'
        )
    reply=chat_completion.choices[0].message.content
    chat_history.append({'role': 'assistant', 'content': reply})
    return chat_history

chat_history=[]  # 儲存對話歷史
api_key=gr.Textbox(label='輸入 OpenAI 金鑰', type='password') 
prompt=gr.Textbox(label='您的詢問: ')
chatbot=gr.Chatbot(type='messages', height=400, placeholder='我們的對話')
iface=gr.Interface(
    fn=ask_gpt,
    inputs=[api_key, prompt],  
    outputs=chatbot,
    title='OpenAI API 聊天機器人',
    flagging_mode='never',
    )
iface.launch()

此例放置了兩個 Textbox 輸入元件, 一個用來貼上 OpenAI API Key, 另一個用來輸入提示詞, 歷史對話紀錄則是輸出到 Chatbot 元件上, 在處理函式 ask_gpt() 中會先把提示詞以 role=user 角色存入 hat_history 串列中, 然後將此串列傳給 API 的 create() 的 messages 參數, 取得回應後將其以 role=assistant 存入 chat_history 串列中, 結果如下 :




可見使用 Chatbot 元件就可以顯示完整的聊天泡泡了. 


2. 利用 Blocks 與 State 元件管理聊天狀態 : 

上面的範例使用全域變數 chat_history  來管理對話歷史不安全, 且可能被 Gradio 重設, 比較理想的方式是使用 Blocks 元件來建立一個區塊語境, 這樣就可以在裡面使用 gr.State 元件來管理聊天狀態. 

將上面的範例修改為如下 :

# gradio_chatbot_test_3.py
import gradio as gr
from openai import OpenAI

def ask_gpt(api_key, prompt, history):
    history.append({'role': 'user', 'content': prompt})
    client=OpenAI(api_key=api_key)
    try:
        chat_completion=client.chat.completions.create(
            messages=history,
            model='gpt-3.5-turbo'
            )
        reply=chat_completion.choices[0].message.content
        history.append({'role': 'assistant', 'content': reply})        
    except Exception as e:
        history.append({'role': 'assistant', 'content': f'發生錯誤:{str(e)}'})
    return history, ""

with gr.Blocks(title="OpenAI 聊天機器人") as blocks:
    api_key=gr.Textbox(label='輸入 OpenAI 金鑰', type='password') 
    chatbot=gr.Chatbot(type='messages',
                       height=400,
                       placeholder='我們的對話',
                       show_copy_button=True)
    state=gr.State([])  # 建立狀態物件儲存對話歷史
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")    
    send_btn.click(ask_gpt, inputs=[api_key, prompt, state], outputs=[chatbot, prompt])
    prompt.submit(ask_gpt, inputs=[api_key, prompt, state], outputs=[chatbot, prompt])
blocks.launch()

此例我們改用 Blocks 物件取代 Interface 物件來排版, 這樣就可以在其語境內用 State 物件來儲存對話歷史, 毋須使用不安全之全域變數. 

當按下按鈕時會呼叫回呼函式 ask_gpt(), 並傳入三個參數, 其中第三個就是儲存對話歷史的 State 物件, 傳入 ask_gpt() 後改名為 history, 新版 Chatbot 元件使用 type='message' 指定 history 格式為 OpenAI 的 List[Dict] 格式, 因此要用 role 與 content 為鍵將 prompt 加入 history 中查詢; 回應也是用相同方式加入 history 中. 由於 click() 的 outputs 參數指定了兩個輸出 (chatbot 與 prompt), 所以 ask_gpt() 需傳回一個 tuple, 第二個元素傳回空字串的目的是要自動清空提示詞欄位讓使用者能直接輸入下一個提示詞. 呼叫 prompt 的 submit() 函式則是用在當使用者在 prompt 輸入框按下 Enter 鍵時做出與按下送出按鈕一樣的效果. 結果如下 :




看來 GPT 是有記住對話紀錄 (但 gpt-3.5-turbo 為何要道歉?). 

2025年2月21日 星期五

Python 學習筆記 : 簡單好用的 Web app 套件 gradio (八)

Gradio 原本去年底我就大致測試完畢, 但最近在測試 DuckDuckGo API 時發現它還有一個好用的 Block 元件, 可以用來自訂 web app 版面, 讓開發者可以更靈活地排列各種 UI 元件, 此外它也提供狀態管理與事件處理功能, 非常適合用來製作 AI 聊天機器人或 Dashboard (儀錶板) 等較複雜的應用程式介面. 

本系列文章索引參考 : 



十一. 用 Block 元件自訂 web app 版面 : 

在之前的 Gradio 測試中, 我們都使用 Interface 類別來建立 web app 的標準網頁介面, 只要用 inputs, outputs, 以及 fn 參數指定輸出入元件與 Submit 按鈕之處理函式即可簡單地建立應用程式介面. 但若要建立結構較複雜的 App, 則 Interface 所提供的簡單且固定的版面就不敷使用了, 這時就需要用到 Blocks 與 Row, Column, Tab, 或 Acordion 等布局元件來自行為介面排版. 

Blocks 類別是一個比 Interface 類別還低階的 API, 呼叫其建構式 Blocks() 會建立一個 Blocks 物件 : 

>>> import gradio as gr 
>>> blocks=gr.Blocks()   
>>> type(blocks)   
<class 'gradio.blocks.Blocks'>   

用 Blocks 布局元件時需要用到 with 語法形成一個語境 (context) 區塊, 然後在此語境內使用 Row, Column, Tab, 或 Acordion 等布局元件為其他 UI 元件進行排版 (也都使用 with 語法) : 

with gr.Blocks() as blocks:
    # 在此 context 內進行 UI 元件布局
    with gr.Row(): # 列布局 
        # 放置水平排列的 UI 元件 (由左向右)
    with gr.Column(): 
        # 放置垂直排列的 UI 元件  (由上而下)
    with gr.Tab():
        # 放置第一個頁籤內的 UI 元件  (由左向右)
    with gr.Tab():
        # 放置第二個頁籤內的 UI 元件  (由左向右)
    with gr.Acordion():
        # 放置手風琴內的 UI 元件  (由左向右)

使用 with 語法的好處是能確保 Blocks 物件在執行完 with 語境後能自動釋放資源, 在語境內部建立的 UI 元件都會被自動加入 Blocks 物件內, 使程式碼更整潔更有可讀性. 

最後在語境外呼叫 Blocks 物件的 launch() 方法即可發布 web app : 
   
blocks.launch()


1. 僅使用 Blocks 排版 : 

這種排版方式不使用 Row, Column, Tab, 或 Acordion 等布局元件, 直接將 UI 元件放在 Blocks 語境內, 這時 UI 元件會流水式依序由左向右, 由上而下排列, 例如下面將輸入文字轉成大寫的簡單範例中, 我們直接在 blocks 語境內建立了一個 TextBox 輸入與輸出元件, 以及一個 Button 元件 : 

>>> with blocks:
 in1=gr.Textbox(label="輸入文字")
 btn=gr.Button("送出")
 out1=gr.Textbox(label="輸出結果")
 btn.click(lambda in1: in1.upper(), inputs=in1, outputs=out1) 

 {'id': 0, 'targets': [(6, 'click')], 'inputs': [5], 'outputs': [7], 'backend_fn': True, 'js': None, 'queue': True, 'api_name': 'lambda', 'scroll_to_output': False, 'show_progress': 'full', 'batch': False, 'max_batch_size': 4, 'cancels': [], 'types': {'generator': False, 'cancel': False}, 'collects_event_data': False, 'trigger_after': None, 'trigger_only_on_success': False, 'trigger_mode': 'once', 'show_api': True, 'zerogpu': False, 'rendered_in': None, 'connection': 'sse', 'time_limit': None, 'stream_every': 0.5, 'like_user_message': False, 'event_specific_args': None}

其中 Button 物件的第一個參數是按鈕事件的處理函式, 並且用關鍵字 inputs 要傳入此函式的輸入元件, 以及用 outputs 指定接收函式傳回值的輸出元件. 

接著只要呼叫 Blocks 物件的 launch() 方法發布此 web app : 

>>> blocks.launch()    
* Running on local URL:  http://127.0.0.1:7860

To create a public link, set `share=True` in `launch()`. 

結果如下 :




按鈕事件處理函式也可以用 def 來定義, 然後於 click() 方法中以 fn 參數指定 :

import gradio as gr

def handler(text):
    return text.upper()

with gr.Blocks() as blocks:
     in1=gr.Textbox(label="輸入文字")
     btn=gr.Button("送出")
     out1=gr.Textbox(label="輸出結果")
     btn.click(fn=handler , inputs=in1, outputs=out1)
     
blocks.launch()

結果是一樣的. 


2. 使用 Row 列布局元件排版 : 

Row 類別用來將 UI 元件以水平方式 (由左向右) 做橫列排版, 呼叫其建構式 Row() 即可建立一個列布局物件, 與 Blocks 物件用法一樣, 也是使用 with gr.Row() 用法建立一個 Row 物件語境, 然後在其內佈放 UI 物件, 例如 : 

import gradio as gr

def handler(text):
    return text.upper()

with gr.Blocks() as blocks:
    with gr.Row():
        in1=gr.Textbox(label="輸入文字")
        btn=gr.Button("送出")
        out1=gr.Textbox(label="輸出結果")
    btn.click(fn=handler , inputs=in1, outputs=out1)
     
blocks.launch()

此處 in1, btn, 與 out1 三個 UI 元件佈放在 Row 物件的語境內, 它們會被水平排列於版面中, 結果如下 : 




可見三個 UI 元件以橫列方式水平排列. 


3. 使用 Column 行布局元件排版 : 

Column 類別用來將 UI 元件以垂直方式 (由上而下) 做垂直排版, 呼叫其建構式 Column() 即可建立一個行布局物件, 與 Blocks 物件用法一樣, 也是使用 with gr.Column() 用法建立一個 Column 物件語境, 然後在其內佈放 UI 物件, 例如 : 

# gradio_blocks_test_3.py
import gradio as gr

def handler(text):
    return text.upper()

with gr.Blocks() as blocks:
    with gr.Column():
        in1=gr.Textbox(label="輸入文字")
        btn=gr.Button("送出")
        out1=gr.Textbox(label="輸出結果")
    btn.click(fn=handler , inputs=in1, outputs=out1)
     
blocks.launch()

此處 in1, btn, 與 out1 三個 UI 元件佈放在 Column 物件的語境內, 它們會被垂直排列於版面中, 結果如下 : 




可見這與上面直接在 Blocks 物件內排版的效果是一樣的. 


4. 使用 Tab 頁籤布局元件排版 :  

Tab 類別用來將 UI 元件以頁籤的方式排版, 呼叫其建構式 Tab() 並傳入頁籤標題即可建立一個頁籤布局物件, 與 Blocks 物件用法一樣, 也是使用 with gr.Tab() 用法建立一個 Tab 物件語境, 然後在其內佈放 UI 物件, 通常需要建立一個以上的 Tab 語境來各自佈放頁籤內之 UI 元件, 例如 : 

# gradio_blocks_test_4.py
import gradio as gr

def upper(text):
    return text.upper()

def lower(text):
    return text.lower()

with gr.Blocks() as blocks:
    with gr.Tab('轉成大寫'):
        in1=gr.Textbox(label="輸入文字")
        btn1=gr.Button("送出")
        out1=gr.Textbox(label="輸出結果")
    with gr.Tab('轉成小寫'):
        in2=gr.Textbox(label="輸入文字")
        btn2=gr.Button("送出")
        out2=gr.Textbox(label="輸出結果")       
    btn1.click(fn=upper, inputs=in1, outputs=out1)
    btn2.click(fn=lower, inputs=in2, outputs=out2)
     
blocks.launch()

此處建立了兩個頁籤物件, 並在各自語境下佈放 UI 元件, 分別達成轉大寫與轉小寫的 web app 功能, 結果如下 : 





按頁籤標題即可切換至轉大寫與轉小寫之頁籤. 


5. 使用 Accordion 手風琴布局元件排版 :  

Accordion 類別用來將 UI 元件以手風琴版面方式排版, 呼叫其建構式 Accordion() 並傳入標題 (第一參數) 即可建立一個手風琴版面物件, 與 Blocks 物件用法一樣, 也是使用 with gr.Accordion() 用法建立一個 Accordion 物件語境, 然後在其內佈放 UI 物件. 手風琴預設是開啟的, 但可以傳入 open=False 將其關閉, 例如 : 

# gradio_blocks_test_5.py
import gradio as gr

def upper(text):
    return text.upper()

def lower(text):
    return text.lower()

with gr.Blocks() as blocks:
    with gr.Accordion('轉成大寫'):
        in1=gr.Textbox(label="輸入文字")
        btn1=gr.Button("送出")
        out1=gr.Textbox(label="輸出結果")
    with gr.Accordion('轉成小寫', open=False):
        in2=gr.Textbox(label="輸入文字")
        btn2=gr.Button("送出")
        out2=gr.Textbox(label="輸出結果")       
    btn1.click(fn=upper, inputs=in1, outputs=out1)
    btn2.click(fn=lower, inputs=in2, outputs=out2)
     
blocks.launch()

此例建立了兩個手風琴物件, 分別佈放轉大寫與轉小寫的 UI 元件, 其中轉大寫的預設是開啟, 轉小寫的社為關閉, 初始化時結果如下 : 





按手風琴右邊的三角形按鈕可以收合或開啟版面 : 




這是關閉轉大寫, 開啟轉小寫版面的效果. 

2025年2月17日 星期一

好站 : 範例檔案資料庫網站 samplelib.com

今天為了測試 Gradio 的 Video 元件搜尋線上 mp4 檔案, 找到下面這個好用的範例文件資料庫網站 samplelib.com :





此網站提供視訊 (MP4, WEBM), 音訊 (MP3, WAV), 圖片 (JPEG, PNG, SVG, GIF), Excel 檔, 廣告橫幅, 以及排版用的 Lorem ipsum 文字檔等範例, 可供直接以 URL 方式線上或下載免費無限制使用. 

以視訊檔為例, 點擊首頁上方 Video 選單中的 mp4 選項會列出所有視訊檔範例列表, 點擊右方按鈕即可下載檔案 :




如果要線上使用, 在視訊檔上按滑鼠右鍵, 點選 "複製影片位址" 即可取得 URL :




例如第一個影片網址為 : 

https://samplelib.com/lib/preview/mp4/sample-5s.mp4

將此 URL 傳給 Gradio 的 Video 元件就可以在 web app 中播放此影片了. 

2025年2月14日 星期五

Python 學習筆記 : 使用 DuckDuckGo API 搜尋網路資料 (二)

在前一篇測試中, 我們對 DuckDuckGo API 用法已有基本了解, 只要安裝第三方套件 duckduckgo-search 即可在應用程式中透過 DuckDuckGo API 搜尋文字, 圖片, 影片, 新聞等內容, 甚至可與 GPT 語言模型聊天, 參考本系列前一篇文章 :


本篇則要使用 Gradio 作為網頁應用程式介面來顯示從 DuckDuckGo 搜尋到的圖片. 


8. 使用 Gradio 製作圖片搜尋介面 : 

Gradio 是 Hugging Face 旗下的一款輕量級開放原始碼 Python web app 套件, 開發者毋須具備任何網頁前端技術 (例如 HTML, CSS, Javascript), 只需少許 Python 程式碼即可快速建構一個網頁應用程式介面, 用法參考 :


Gradio 官網提供 Playground 讓開發者線上撰寫 Web app, 網址如下 : 


在 Gradio Playgorund 上編寫的應用程式可以一鍵部署到 Hugging Face 的免費 Web app 空間 Hugging Face Space (也可付費取得更多資源), 參考 : 


Hugging Face Space 已安裝一些常用的 Python 第三方套件, 例如 Numpy, Pandas, Matplotlib 等等, 通常在 Gradio Playgorund 上可以順利執行的 Web app 發布到 Hugging Face Space 後應該均可順利運行, 如果出現找不到套件或模組的錯誤, 就要到 Hugging Face Space 上手動新增一個專案, 並在 requirement.txt 檔案中指定要安裝之第三方套件, 參考 :


不過壞消息是, 由於透過 DuckDuckGo API 搜尋資料需要 requests 或 urllib 等爬蟲套件, 但 Gradio Playground 與 Hugging Face Space 可能基於資安原因均無法執行這些爬蟲套件, 因此以下測試均在本機執行. 

Gradio 用來展示圖片的元件為 Gallery, 用法參考 : 


全部 Gradio 測試紀錄參考索引 :


測試程式碼如下 :

# gradio-duckduckgo-images-gallery-1.py
import gradio as gr
from duckduckgo_search import DDGS

def handler(query, max_results):
    results=ddgs.images(query, max_results=int(max_results))  # 需用 int() 轉成整數
    return [result['thumbnail'] for result in results]
    
ddgs=DDGS()
in1=gr.Textbox(label='輸入關鍵字', value='可愛的貓')
in2=gr.Textbox(label='設定圖片數量', value=8)
out1=gr.Gallery(label='DuckDuckGo 圖片搜尋結果', columns=4, object_fit='scale-down')
iface=gr.Interface(
    fn=handler,
    inputs=[in1, in2],
    outputs=out1,
    title='DuckDuckGo 圖片搜尋測試',
    flagging_mode='never',
    )
iface.launch()

此例設置了兩個 TextBox 輸入元件, in1 負責接收搜尋關鍵字, in2 負責接收欲取得之圖片數量 (即取前幾個搜尋結果), 這兩個參數會依序傳入處理函式 fn=handler, 分別對應到 query 與 max_results, 然後傳給 DDGS 物件的 images() 方法進行圖片搜尋, 然後從 DuckDuckGo API 回應的字典串列中, 取出 thumbnail 鍵所攜帶的縮圖 URL 組成串列傳回給 Gallery 元件顯示. 注意, 由於 Textbox 元件輸出字串, 但 max_results 參數要求為一個整數, 故需先用 int() 轉換. 

結果如下 : 




因為 Gallery 元件指定 columns=4, 所以圖片會以 4 欄呈現, 點擊這些小圖就會彈出一個較大視窗來顯示這些縮圖 (不是原始圖檔). 如果將 handler() 中的傳回值改成擷取原本圖檔的 URL 鍵 image 亦可 : 

return [result['image'] for result in results]

但因為 Gradio 的 Gallery 能顯示的圖檔尺寸有限, 太大的圖檔會無法呈現, 例如 : 




也可以用 Number 或 Slider 元件來設定圖片數量, 它們都是傳回數值, 因此毋須使用 int() 來轉換 in2 元件的傳出值, 例如 : 

# gradio-duckduckgo-images-gallery-2.py
import gradio as gr
from duckduckgo_search import DDGS

def handler(query, max_results):
    results=ddgs.images(query, max_results=max_results)
    return [result['thumbnail'] for result in results]
    
ddgs=DDGS()
in1=gr.Textbox(label='輸入關鍵字', value='可愛的貓')
in2=gr.Number(label='設定圖片數量', minimum=1, value=8, maximum=100)
out1=gr.Gallery(label='DuckDuckGo 圖片搜尋結果', columns=4, object_fit='scale-down')
iface=gr.Interface(
    fn=handler,
    inputs=[in1, in2],
    outputs=out1,
    title='DuckDuckGo 圖片搜尋測試',
    flagging_mode='never',
    )
iface.launch()

結果如下 : 





下面是使用 Slider 的範例 :

# gradio-duckduckgo-images-gallery-3.py
import gradio as gr
from duckduckgo_search import DDGS

def handler(query, max_results):
    results=ddgs.images(query, max_results=max_results)
    return [result['thumbnail'] for result in results]
    
ddgs=DDGS()
in1=gr.Textbox(label='輸入關鍵字', value='可愛的貓')
in2=gr.Slider(label='設定圖片數量', minimum=1, value=8, maximum=100, step=1)
out1=gr.Gallery(label='DuckDuckGo 圖片搜尋結果', columns=4, object_fit='scale-down')
iface=gr.Interface(
    fn=handler,
    inputs=[in1, in2],
    outputs=out1,
    title='DuckDuckGo 圖片搜尋測試',
    flagging_mode='never',
    )
iface.launch()

由於 Slider 滑動步階預設為 0.1, 因此這裡要將 step 設為 1 (上面的 Number 元件沒有 step 參數, 因為它固定為 1, 結果如下 : 



2025年1月5日 星期日

Python 學習筆記 : 在 Gradio 與 Streamlit app 上繪製 K 線圖

Python 用來繪製 K 線圖的主要套件是 mplfinance, 它可根據傳入的 OHLCV 價量資料 DataFrame 繪製 K 線, 並提供了豐富的函式可繪製疊圖與副圖, 搭配 Ta-Lib 套件則可在副圖中繪製各種技術指標, 我也寫了一個 kbar.py 模組來簡化畫 K 線圖時的參數設定, 參考 :

但若要同時可在命令列以及 Gradio 與 Streamlit 的 web app 中利用此 kbar.py 繪製 K 線圖的話, 必須改寫之前的 kbar.py, 因為 mplfinance 的 plot() 函式預設傳回 None, 這在命令列執行沒有問題, 但若要在 Gradio 與 Streamlit 的 web app 中使用 mplfinance 畫 K 線圖, 則必須傳入 returnfig=True 參數, 這樣 plot() 函式就會傳回 (fig, axes) 元組, 只要將畫布物件 fig 傳給 Gradio 的 Plot() 函式或 Streamlit 的 pyplot() 函式即可. 修改後的 kbar.py 如下 : 

# kbar.py
import mplfinance as mpf

class KBar():
    def __init__(self, df):
        self.df=df
        self.addplots=[]
    def addplot(self, data, **kwargs):
        plot=mpf.make_addplot(data, **kwargs)
        self.addplots.append(plot)
    def plot(self, embedding=False, **kwargs):
        color=mpf.make_marketcolors(up='red', down='green', inherit=True)   
        font={'font.family': 'Microsoft JhengHei'}   
        style=mpf.make_mpf_style(base_mpf_style='default',
                                 marketcolors=color,
                                 rc=font)
        kwargs['type']='candle'
        kwargs['style']=style
        kwargs['addplot']=self.addplots
        if embedding:
            fig, axes=mpf.plot(self.df, returnfig=True, **kwargs)
            return fig
        else:
            mpf.plot(self.df, **kwargs)

主要是在 KBar 類別的 plot() 方法中添加 embedding 參數, 預設值為 False, 因此在命令列呼叫 plot() 方法時不必傳入 embedding 參數; 但在 Gradio 或 Streamlit app 中則要傳入 embedding=True. 

以下測試使用 yfinance 套件取得台股的 OHLCV 價量資料, 用法參考 :



1. 在 Gradio app 中繪製 K 線圖 : 

Gradio 是 HuggingFace 所維護的開放原始碼 web app 套件, 關於 Gradio 用法參考 :


以下是在 Gradio 中使用 mplfinance 畫 K 線圖範例 :

# gradio_kbar_1.py 
import gradio as gr
import kbar  # 匯入 kbar
import yfinance as yf  # 匯入 Yahoo Finance 
from talib.abstract import RSI, MFI  # 匯入 Ta-Lib 的技術指標類別

def plot_kbar():
    df=yf.download('0050.tw', start='2024-07-01', end='2024-08-21')  # 取得價量資料
    df.columns=[column.lower() for column in df.columns]  # Ta-Lib 要求欄位名稱為全小寫
    kb=kbar.KBar(df)  # 建立 KBar 物件
    rsi=RSI(df)  # 建立 RSI 指標物件
    mfi=MFI(df, timeperiod=14)  # 建立 MFI 指標物件
    if not rsi.isna().all():  # 確保 RSI 有值
        kb.addplot(rsi, panel=2, ylabel='RSI')
    if not mfi.isna().all():  # 確保 MFI 有值
        kb.addplot(mfi, panel=3, ylabel='MFI')
    # 繪製 K 線圖與技術指標副圖    
    fig=kb.plot(volume=True, mav=[3, 5, 7], embedding=True, title='台灣五十')  
    return fig  # 傳回 Figure 物件

iface=gr.Interface(
    fn=plot_kbar,  # 繪製函數
    inputs=[],              # 無需用戶輸入
    outputs=gr.Plot(),      # 輸出為 Gradio 的 Plot 物件
    title='Gradio Candle Chart',
    flagging_mode='never',
    )

iface.launch()

此例在呼叫 KBar 物件的 plot() 方法時傳入 embedding=True 參數, 這樣就會傳回畫布物件 fig, 將其傳回給 Gradio.Plot() 即可在 Gradio app 中顯示 mplfinance 繪製的圖形, 結果如下 :




2. 在 Streamlit app 中繪製 K 線圖 :

Streamlit 是一個輕量好用的 Python web app 套件, 廣泛應用在機器學習, 資料科學, 與人工智慧專案的展示應用上. 下面是在 Streamlit app 中用 mplfinance 繪製 K 線圖範例 : 

# streamlit_kbar_1.py
import streamlit as st
import kbar  # 匯入 kbar
import yfinance as yf  # 匯入 Yahoo Finance
from talib.abstract import RSI, MFI  # 匯入 Ta-Lib 的技術指標類別

df=yf.download('0050.tw', start='2024-07-01', end='2024-08-21')  # 取得價量資料
df.columns=[column.lower() for column in df.columns]  # Ta-Lib 要求欄位名稱為全小寫
kb=kbar.KBar(df)  # 建立 KBar 物件
rsi=RSI(df)  # 建立 RSI 指標物件
mfi=MFI(df, timeperiod=14)  # 建立 MFI 指標物件
if not rsi.isna().all():  # 確保 RSI 有值
    kb.addplot(rsi, panel=2, ylabel='RSI')
if not mfi.isna().all():  # 確保 MFI 有值
    kb.addplot(mfi, panel=3, ylabel='MFI')
# 繪製顯示成交量與 3, 5, 7 日均線之 K 線圖
fig=kb.plot(volume=True, mav=[3, 5, 7], embedding=True)  
st.title("Streamlit Candle Chart")
st.pyplot(fig)

此例在呼叫 KBar 物件的 plot() 方法時傳入 embedding=True 參數, 這樣就會傳回畫布物件 fig, 將其傳給 Streamlit 的 pyplot() 函式即可在 Streamlit app 中顯示 mplfinance 繪製的圖形, 結果如下 :




2025-01-21 補充 : 

今天測試 kbar.py 時發現一個 Bug : 如果在呼叫 plot() 方法時傳入 returnfig 參數會出現 duplicate rturnfig 錯誤, 原因在於傳入 plot() 的 returnfig 會出現在 **kwargs 的字典鍵中, 而 kbar.py 的下列程式碼又傳入 returnfig=True 變成有兩個 returnfig 鍵導致錯誤發生 : 

if embedding:
            fig, ax=mpf.plot(self.df, returnfig=True, **kwargs)

原來的 kbar.py 因為此嵌入用途新增了一個 embedding 參數實屬多此一舉, 應該回歸只使用原生地 returnfig 參數來控制要不要傳回 Figure 物件才對, 茲將 kbar.py 修正如下 :

# kbar.py
import mplfinance as mpf

class KBar():
    def __init__(self, df):
        self.df=df
        self.addplots=[]
    def addplot(self, data, **kwargs):
        plot=mpf.make_addplot(data, **kwargs)
        self.addplots.append(plot)
    def plot(self, **kwargs):
        color=mpf.make_marketcolors(up='red', down='green', inherit=True)   
        font={'font.family': 'Microsoft JhengHei'}   
        style=mpf.make_mpf_style(base_mpf_style='default',
                                 marketcolors=color,
                                 rc=font)
        kwargs['type']='candle'
        kwargs['style']=style
        kwargs['addplot']=self.addplots
        if 'returnfig' in kwargs and kwargs['returnfig'] is True:
            fig, axes=mpf.plot(self.df, **kwargs)
            return fig, axes
        else:
            mpf.plot(self.df, **kwargs)

此處先判斷 kwargs 裡面有無 returnfig 參數傳入, 有的話且為 True 表示圖需內嵌故傳回 Figure 物件, 否則就不傳回 Figure 物件, 經測試不管在內嵌或一般使用情境皆可正常繪製圖形. 改用此新版 kbar.py 後, 上面範例程式中的 embedding=True 都要配合改成 returnfig=True.


2025-01-23 補充 : 

今天在進行 kbar.py 打包上傳 PyPi 的準備時重新審視其功能完善性, 發覺在傳入 returnfig=True 時只傳回 Figure 畫布物件似乎不夠完備, 應該連同繪圖區 (軸) 物件串列 Axes 也一起傳回去才對, 這樣在需要進一步自訂繪圖 (例如在主圖上畫一條額外的水平線) 時才有辦法利用 Axes 物件進行加工, 所以進一步修改 kbars.py 為如下 : 

# kbar.py
import mplfinance as mpf

class KBar():
    def __init__(self, df):
        self.df=df
        self.addplots=[]
    def addplot(self, data, **kwargs):
        plot=mpf.make_addplot(data, **kwargs)
        self.addplots.append(plot)
    def plot(self, **kwargs):
        color=mpf.make_marketcolors(up='red', down='green', inherit=True)   
        font={'font.family': 'Microsoft JhengHei'}   
        style=mpf.make_mpf_style(base_mpf_style='default',
                                 marketcolors=color,
                                 rc=font)
        kwargs['type']='candle'
        kwargs['style']=style
        kwargs['addplot']=self.addplots
        if 'returnfig' in kwargs and kwargs['returnfig'] is True:
            fig, axes=mpf.plot(self.df, **kwargs)
            return fig, axes 
        else:
            mpf.plot(self.df, **kwargs)

這樣上面的 Gradio 範例程式碼要修改為 : 

# gradio_kbar_2.py 
import gradio as gr
import kbar  # 匯入 kbar
import yfinance as yf  # 匯入 Yahoo Finance 
from talib.abstract import RSI, MFI  # 匯入 Ta-Lib 的技術指標類別

def plot_kbar():
    df=yf.download('0050.tw', start='2024-07-01', end='2024-08-21')  # 取得價量資料
    df.columns=[column.lower() for column in df.columns]  # Ta-Lib 要求欄位名稱為全小寫
    kb=kbar.KBar(df)  # 建立 KBar 物件
    rsi=RSI(df)  # 建立 RSI 指標物件
    mfi=MFI(df, timeperiod=14)  # 建立 MFI 指標物件
    if not rsi.isna().all():  # 確保 RSI 有值
        kb.addplot(rsi, panel=2, ylabel='RSI')
    if not mfi.isna().all():  # 確保 MFI 有值
        kb.addplot(mfi, panel=3, ylabel='MFI')
    # 繪製 K 線圖與技術指標副圖    
    fig, axes=kb.plot(volume=True, mav=[3, 5, 7], returnfig=True, title='台灣五十')  
    return fig  # 傳回 Figure 物件

iface=gr.Interface(
    fn=plot_kbar,  # 繪製函數
    inputs=[],              # 無需用戶輸入
    outputs=gr.Plot(),      # 輸出為 Gradio 的 Plot 物件
    title='Gradio Candle Chart',
    flagging_mode='never',
    )

iface.launch()

而 Streamlit 的範例程式碼也要修改為 :

# streamlit_kbar_2.py
import streamlit as st
import kbar  # 匯入 kbar
import yfinance as yf  # 匯入 Yahoo Finance
from talib.abstract import RSI, MFI  # 匯入 Ta-Lib 的技術指標類別

df=yf.download('0050.tw', start='2024-07-01', end='2024-08-21')  # 取得價量資料
df.columns=[column.lower() for column in df.columns]  # Ta-Lib 要求欄位名稱為全小寫
kb=kbar.KBar(df)  # 建立 KBar 物件
rsi=RSI(df)  # 建立 RSI 指標物件
mfi=MFI(df, timeperiod=14)  # 建立 MFI 指標物件
if not rsi.isna().all():  # 確保 RSI 有值
    kb.addplot(rsi, panel=2, ylabel='RSI')
if not mfi.isna().all():  # 確保 MFI 有值
    kb.addplot(mfi, panel=3, ylabel='MFI')
# 繪製顯示成交量與 3, 5, 7 日均線之 K 線圖
fig, axes=kb.plot(volume=True, mav=[3, 5, 7], returnfig=True)  
st.title("Streamlit Candle Chart")
st.pyplot(fig)

這兩個範例使用新的 kbar.py 測試 OK.