Streamlitで便利なアプリ作ってみた

# はじめに

CIST (公立千歳科学技術大学) Advent Calendar2023、13日目の記事です！！

https://qiita.com/advent-calendar/2023/cist

約半年弱、大学のプロジェクトの関係などもあり[Streamlit](https://streamlit.io/)というPythonライブラリを勉強してきたので、その成果についてまとめてみました。

# Streamlitとは？

**Streamlit**とは、Pythonのライブラリであり、Webアプリケーションを作成するためのフレームワークです。

PythonでWebアプリ開発を行いたい場合、[Flask](https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/index.html)や[Django](https://docs.djangoproject.com/ja/4.1/)といったライブラリが真っ先に挙げられます。   
しかし、それらを学ぶには、HTML・CSS・JavaScriptなどのフロントエンドの知識がある程度必要になります…

これらの技術を学ぶことなく、動くアプリを速く簡単に作ることができるのがStreamlitの大きな特徴です。

なお、PythonでWEBアプリを作ることができるツールはほかにも存在します。気になる方は[この記事](https://zenn.dev/neka_nat/articles/f2f5b6ebeb049a)などが分かりやすいと思います！！

# Streamlit学習のきっかけ

Streamlitの存在を知ったのは、学部2年の2月ごろ、統計の勉強をしながらも一応時間ができたタイミングでした。

このとき、大学の授業外プロジェクトで、何かしらの成果物を作ることをゴールとした、後輩へのPythonプログラミングの指導をするという取り組みがスタートしました。
主に資料作成担当であった私は、どうにかして学習コストを低くWEBアプリを作れないかと色々リサーチ👀していたところ、Streamlitの存在を知りました。
フロントエンド系の言語の学習が不要であることに加えて、[Streamlit Cloud](https://streamlit.io/cloud)を使えば無料でアプリを公開できることに魅力を感じ、指導のため学習をスタートさせました。

今日までそこそこ大規模なアプリも含めて作ってきましたが、まだまだ奥が深いです…

# 制作物

Streamlitを学習した期間は長いですが、しっかりと成果と誇れる制作物は大きく3つです。

https://github.com/kiryu-3/Streamlit-SegKao

## 画像処理アプリ

<details><summary>Code</summary><div>

```python
import streamlit as st
from rembg import remove
from PIL import Image
import os
import requests
import io
from io import BytesIO
import time

# 画像URLを指定
image_url = "https://imgur.com/jucjTMY.jpg"

# 画像をダウンロードしPILのImageオブジェクトとして読み込む
response = requests.get(image_url)
image = Image.open(BytesIO(response.content))

# Streamlit ページの設定
st.set_page_config(
    page_title="Image-Hub",
    page_icon=image,
    layout="wide",
    initial_sidebar_state="expanded"
)

hide_menu_style = """
    <style>
    #MainMenu {visibility: hidden;}
    </style>
"""
st.markdown(hide_menu_style, unsafe_allow_html=True)


st.write('# Image Hub')
st.file_uploader("File Upload", type=('jpg', 'png', 'jpeg'),
                  key="uploaded_image",
                  accept_multiple_files=False)

# 画像がアップロードされたとき
if st.session_state["uploaded_image"] is not None:

  st.session_state["first_image"] = Image.open(st.session_state["uploaded_image"])
  st.session_state["edit_image"] = Image.open(st.session_state["uploaded_image"])
  # if 'edit_image' not in st.session_state:  # 初期化
    # st.session_state["edit_image"] = Image.open(st.session_state["uploaded_image"])
  if 'count' not in st.session_state:  # 初期化
    st.session_state["count"] = 0

  col1 = st.columns(3)

  resize_title = col1[0].empty()
  resize_expander = col1[0].empty()
  radio_area = col1[0].empty()
  width_area = col1[0].empty()
  height_area = col1[0].empty()

  resize_title.write('## Image Resize')
  with resize_expander.expander("See explanation"):
    st.markdown("""
      - You can specify the width and height of the image in pixels
      - After checking the reflected image, you can download it

      Reference <<<Recommended size>>
      - Discord custom pictograms: 128px x 128px
      - Custom server icon for Discord: 512px x 512px or larger
      - Notion custom icon: 280px x 280px
    """)




  # col1[0].write(f'幅：{st.session_state["edit_image"].width}px　高さ：{st.session_state["edit_image"].height}px')

  col1[1].write('## Transparent')
  with col1[1].expander("See explanation"):
    st.markdown("""
      - You can specify the width and height of the image in pixels
      - After checking the reflected image, you can download it
    """)

  def checkbox_callback():
    checkbox_value = st.session_state.checkbox_key  # ここで処理を行う
    if checkbox_value:
      st.session_state["edit_image"] = remove(st.session_state["edit_image"])
    else:
      st.session_state["edit_image"] = Image.open(st.session_state["uploaded_image"])

  # col1[1].checkbox('Transparent', key="checkbox_key", on_change=checkbox_callback)

  col1[1].checkbox('Transparent', key="checkbox_key")
  if st.session_state["checkbox_key"]:
    st.session_state["edit_image"] = remove(st.session_state["edit_image"])
  else:
    st.session_state["edit_image"] = Image.open(st.session_state["uploaded_image"])


  col1[2].write('## Rotation Angle')
  with col1[2].expander("See explanation"):
    st.markdown("""
      - Rotation angle can be set
      - After checking the reflected image, you can download it
    """)

  def rotate_renew():
    st.session_state["count"] += 90

  col1[2].button("Turn", on_click=rotate_renew)


  col2 = st.columns(2)

  col2[0].write("### Image before Reflection")
  col2[0].image(st.session_state["first_image"],
                    caption = f'幅：{st.session_state["first_image"].width}px　高さ：{st.session_state["first_image"].height}px',
                    use_column_width="auto",)

  st.session_state["edit_image"] = st.session_state["edit_image"].rotate(st.session_state["count"], expand=True)
  edit_width = st.session_state["edit_image"].width
  edit_height = st.session_state["edit_image"].height

  # ラジオボタンで選択肢を切り替え
  selected_option = radio_area.radio("Select input type:", ["Number Input", "Slider"], horizontal=True)
  if selected_option == "Number Input":
    # Number Inputの場合
    width_area.number_input("Select the width to resize (px)", min_value=1, max_value=edit_width*10, value=edit_width, step=1, key="resize_width")
    height_area.number_input("Select the height to resize (px)", min_value=1, max_value=edit_height*10, value=edit_height, step=1, key="resize_height")
  else:
    width_area.slider("Select the width to resize (px)", 1, edit_width*10, edit_width, step=1, key="resize_width",)
    height_area.slider("Select the height to resize (px)", 1, edit_height*10, edit_height, step=1, key="resize_height")
  st.session_state["edit_image"] = st.session_state["edit_image"].resize((st.session_state["resize_width"], st.session_state["resize_height"]))

  image_area_title = col2[1].empty()
  image_area_title.write("### Image after Reflection")

  status_area = col2[1].empty()
  bar_area = col2[1].empty()
  bar = bar_area.progress(0)
  for i in range(100):
      status_area.text(f'Iteration {i+1}')
      bar.progress(i+1)
      time.sleep(0.1)
  status_area.empty()
  bar_area.empty()

  col2[1].image(st.session_state["edit_image"],
                  caption = f'幅：{st.session_state["edit_image"].width}px　高さ：{st.session_state["edit_image"].height}px',
                  use_column_width="auto",)


  image_name = st.session_state["uploaded_image"].name.split(".")[0]
  image_type = st.session_state["uploaded_image"].name.split(".")[1]

  # 画像のバイナリエンコード
  img_byte_array = io.BytesIO()
  st.session_state["edit_image"].save(img_byte_array, format='PNG')
  img_byte_array = img_byte_array.getvalue()

  # st.write("ファイル名を入力してください")
  st.text_input(
      label="Please Input download filename and Enter to Apply",
      value=f"{image_name}_edited",
      key="download_name"
  )
  st.download_button(
      label="Download Image",
      data=img_byte_array,
      file_name=f'{st.session_state["download_name"]}.{image_type}'
  )
else:
  st.session_state["count"] = 0
  st.session_state["remove_flag"] = False
```

</div></details>

[Notion](https://www.notion.so/ja-jp)や[Discord](https://discord.com/)で作業する機会が増えたのですが、  
そのときに（小さいサイズの）アイコンを作る機会が何度かありました。

このときに主にしていた作業を簡単・同時に行いたかったので、本アプリを作りました。  
以下3つの作業が可能です。
- 画像サイズの変更  
- 背景色透明化（ライブラリ**Rembg**を使用）
- 画像回転

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3243081/a6cf5c7f-cb39-c4d9-08d9-8aa9c6e4cccc.png)


アプリリンク：https://image-hb.streamlit.app/

## 多言語翻訳アプリ

<details><summary>Code</summary><div>

``` py
import streamlit as st
from PIL import Image
from gtts import gTTS
import os
import base64
import langdetect
import requests
import io
from io import BytesIO
import boto3
import json


# 画像URLを指定
image_url = "https://imgur.com/3ZfMAyY.jpg"

# 画像をダウンロードしPILのImageオブジェクトとして読み込む
response = requests.get(image_url)
image = Image.open(BytesIO(response.content))

# Streamlit ページの設定
st.set_page_config(
    page_title="CSV Filters",
    page_icon=image,
    layout="wide",
    initial_sidebar_state="expanded"
)

hide_menu_style = """
    <style>
    #MainMenu {visibility: hidden;}
    </style>
"""
st.markdown(hide_menu_style, unsafe_allow_html=True)


# シークレットから秘密情報を取得する
access_key = st.secrets["access_key"]
secret_key = st.secrets["secret_key"]
region_name = st.secrets["region_name"] 

session = boto3.Session(aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region_name)
comprehend = session.client('comprehend')
translate = session.client('translate')
polly = session.client('polly')

# 現在のスクリプトのディレクトリを取得
script_directory = os.path.dirname(os.path.abspath(__file__))  

# JSONファイルからデータを読み込む
with open(os.path.join(script_directory, 'languages_data.json'), "r", encoding="utf-8") as json_file:
    loaded_languages_data = json.load(json_file)

if 'mapping' not in st.session_state:  # 初期化
    st.session_state['mapping'] = loaded_languages_data["before_languages"]


if 'select_languages' not in st.session_state:  # 初期化
    st.session_state['select_languages'] = loaded_languages_data["after_languages"]

if 'selected_languages' not in st.session_state:  # 初期化
    st.session_state['selected_languages'] = st.session_state['select_languages']
if "honyaku_mode" not in st.session_state:  # 初期化
    st.session_state["honyaku_mode"] = False

text_area = st.empty()
button_area = st.empty()
# cols = st.columns([3, 7])

if 'voices' not in st.session_state:  # 初期化
  st.session_state['voices'] = loaded_languages_data["voice_languages"]

if "language_code" not in st.session_state:  # 初期化
    st.session_state['language_code'] = ""

if "input_language" not in st.session_state:  # 初期化
    st.session_state['input_language'] = ""

def nlp():
    if st.session_state["tab3_input_text"] != "":
        st.session_state["honyaku_mode"] = True
        st.session_state['input_language'] = ""
        response = comprehend.detect_dominant_language(Text=st.session_state["tab3_input_text"])
        st.session_state["language_code"] = response['Languages'][0]['LanguageCode']

        # マッピングから言語名を取得
        st.session_state["language_name"] = st.session_state["mapping"][st.session_state["language_code"]]
        st.session_state['selected_languages'] = [lang if lang != st.session_state['language_name'] else f"{lang}(Original)" for lang in st.session_state['select_languages']]
        # "original" を含む要素がある場合、それを先頭に移動
        st.session_state['selected_languages'].sort(key=lambda x: "Original" not in x)
        # st.session_state['selected_languages'] = [lang if lang != st.session_state['language_name'] else f"{lang} (元言語)" for lang in st.session_state['select_languages']]
        # st.session_state['selected_languages'] = [lang for lang in st.session_state['select_languages'] if lang != st.session_state['language_name']]

    else:
        st.session_state["honyaku_mode"] = False
        st.session_state["language_code"] = ""
        st.session_state["language_name"] = ""
        st.session_state['selected_languages'] = st.session_state['select_languages']
        st.session_state['translated_text'] = ""


def honyaku():
    reverse_mapping = {v: k for k, v in st.session_state['mapping'].items()}
    try:
        input_language = st.session_state["input_language"].replace("(Original)", "")

        response = translate.translate_text(
            Text=st.session_state["tab3_input_text"],
            SourceLanguageCode= st.session_state["language_code"],
            TargetLanguageCode=reverse_mapping[input_language]
        )
        st.session_state["translated_text"] = response['TranslatedText']
        response = polly.synthesize_speech(
            Text=st.session_state["translated_text"],
            OutputFormat='mp3',
            VoiceId=st.session_state['voices'][input_language]
        )
        st.session_state['audio_stream'] = response['AudioStream'].read()

        # st.session_state["cols"][1].write(f"言語：{st.session_state['input_language']}")
        # st.session_state["cols"][1].write(f"テキスト：{st.session_state['translated_text']}")
        # # 音声をバイナリストリームとして再生する
        # st.session_state["cols"][1].audio(BytesIO(audio_stream), format='audio/mp3')

    except Exception as e:
        st.session_state['audio_stream'] = ""
        # error_message = str(e)
        # st.error(error_message)

def start():
    if st.session_state["tab3_input_text"] != "":
        button_area.button(label="Go!", on_click=nlp)
        st.session_state["honyaku_mode"] = True

st.write('# VoiceTranslate Hub')

with st.expander("Explanation"):
    st.markdown("""
      - Can translate input text into a specified language
      - The translated text can be downloaded in mp3 format after being converted to audio
    """)
    # st.write("Can translate input text into a specified language")
    # st.write("The translated text can be downloaded in mp3 format after being converted to audio")

    response2 = requests.get("https://imgur.com/YLszTa4.png")
    image_example2 = Image.open(BytesIO(response2.content))
    st.image(image_example2)

st.text_area(label="Please enter the sentence to be translated",
                key="tab3_input_text",
                height=200,
                value="おはようございます")
st.button(label="Go to Translate!", on_click=nlp, key="tab3")

# st.session_state["cols"] = st.columns([3, 7])
if st.session_state["honyaku_mode"]:
    st.selectbox(
                label="Please select the language you wish to interpret",
                options=[""]+ st.session_state['selected_languages'],
                key="input_language",
                on_change=honyaku
            )
if st.session_state["input_language"] != "" and st.session_state["audio_stream"] != "":
    # st.write(f"言語：{st.session_state['input_language']}")
    with st.expander("Post-translation text"):
      st.text_area(label="Post-translation text", height=200, value=st.session_state["translated_text"], disabled=True)

    # 音声をバイナリストリームとして再生する
    # audio_stream から BytesIO オブジェクトを作成する
    audio_bytes = BytesIO(st.session_state['audio_stream'])
    st.audio(audio_bytes, format='audio/mp3')

    input_language = st.session_state["input_language"].replace("(Original)", "")
    response2 = translate.translate_text(
        Text=input_language,
        SourceLanguageCode= "ja",
        TargetLanguageCode= "en"
    )

    # st.write("ファイル名を入力してください")
    st.text_input(
        label="Press Input download filename and Enter to Apply",
        value=f"output_{response2['TranslatedText']}",
        key="tab3_download_name"
    )
    st.download_button(
        label="Download MP3 files",
        data=audio_bytes.read(),
        file_name=f'{st.session_state["tab3_download_name"]}.mp3',
        key="tab3_downloader"
    )
```

</div></details>

大学の授業で、AWSの何かサービスを使って成果物を作るという課題が出たので、pythonのboto3ライブラリを使って作成しました。  
AWSの [Comprehend（テキスト言語特定）](https://aws.amazon.com/jp/comprehend/)&[ translate（翻訳）](https://aws.amazon.com/jp/translate/)&[ Polly（音声化）](https://aws.amazon.com/jp/polly/)というサービスを用いています。

他の翻訳サイトと違うのは、翻訳できる言語の数の多さ、そしてテキストを指定の言語で音声化できるところです。
以下の機能があります。
- 入力されたテキストをそのまま音声化  
- 入力されたテキストを翻訳→テキスト・音声化
- 音声の再生&mp3ダウンロード

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3243081/a0054e03-7b81-6891-703e-b7a59cad2565.png)


アプリリンク：https://voicetranslate-hb.streamlit.app/

## 人流データ可視化・分析アプリ

大学のプロジェクトで、ポイント人流データの分析・可視化ツールを作る機会がありました。  
今回メインで使ったライブラリが`Streamlit-folium`と呼ばれる地図のライブラリです。ユーザがクリックした座標を取得できる点が大きな特徴です。

人流データのあるCSVファイルをアップロードし、その座標情報をプロットし経過も再生ボタンを押すことで表示させることができるようなシステムです。
- 表示されている地図に長方形や多角形やラインを描画することができる
- このゲートの上を何人のユーザーが通過したかもポップアップで表示することができる
- 時間にゲートを通過したのか、グラフで可視化できる

また、データをアップロードする際にはCSVの形式を定められた形に成形する必要があるので、
CSVの加工&フィルタリングの付属機能も付けました。

<details><summary>トップ画面</summary><div>

![](https://imgur.com/vMVFTsK.png)
- 人流データの分析・可視化ツールとCSVの加工&フィルタリング機能の説明が書かれている。
- CSVの形式の確認やサンプルデータのダウンロードが行える。
</div></details>

<details><summary>CSVの加工&フィルタリング</summary><div>

![](https://imgur.com/3RAe0yq.png)
- エクセルファイルorCSVファイルをアップロードできる。
- エクセルで操作している感覚でデータの加工を簡単に行える。エクセルをイチイチ開かなくていいので楽。
- 想定としては人流のツールに使ってもらうための機能だが、単体でも十分ニーズが高そう。
</div></details>

<details><summary>人流データの分析・可視化ツール</summary><div>

![](https://imgur.com/bGA17Jw.png)

- エクセルファイルorCSVファイルをアップロードできる。
- エクセルで操作している感覚でデータの加工を簡単に行える。エクセルをイチイチ開かなくていいので楽。
- 想定としては人流のツールに使ってもらうための機能だが、単体でも十分ニーズが高そう。

<details><summary>streamlit-folium</summary><div>



``` python
import streamlit as st
from  streamlit_folium import st_folium
import folium
from folium import plugins
from folium.plugins import Draw, TimestampedGeoJson

m = folium.Map()
# Leaflet.jsのDrawプラグインを追加
draw_options = {'polyline': True, 'rectangle': True, 'circle': True, 'marker': False, 'circlemarker': False}
draw = folium.plugins.Draw(export=False, position='topleft', draw_options=draw_options)
draw.add_to(m)

st.session_state['map'] = m

# 表示する地図
st_data = st_folium(st.session_state['map'], width=800, height=800, zoom=st.session_state['zoom_level'], center=st.session_state['center'])
```

- streamlit-folium：Streamlitアプリケーション内でFoliumで作成した地図を簡単に表示できるようにするためのラッパーライブラリ。
- folium：Python用の地図可視化ライブラリで、Leaflet.jsを使用してインタラクティブな地図を作成できる。
- Leaflet.jsのDrawプラグインを使用することで、地図上でポリライン、矩形、円などの図形を描画できるようになる。


- `streamlit-folium`パッケージの`st_folium`関数を使用して、`Streamlit`アプリケーション内で地図を表示する。
- `st_folium`関数には、地図の幅、高さ、ズームレベル、中心座標などのパラメータを指定できる。
- ユーザーが地図上で行った操作の情報を取得するために、`st_folium`関数の戻り値を`st_data`変数に代入している。


<details><summary>response</summary><div>

```python
{
    # ユーザが最後にクリックした座標の情報
    "last_clicked": None,
    
    # ユーザが最後にクリックしたオブジェクトの座標情報
    "last_object_clicked": {
        "lat": 42.790487922849444,
        "lng": 141.68955802917483
    },
    
    # ユーザが最後にクリックしたオブジェクトのツールチップ情報
    "last_object_clicked_tooltip": None,
    
    # ユーザが最後にクリックしたオブジェクトのポップアップ情報
    "last_object_clicked_popup": None,
    
    # 描画された全てのオブジェクトの情報
    "all_drawings": [],
    
    # 最後にアクティブになった描画オブジェクトの情報
    "last_active_drawing": None,
    
    # 地図の表示範囲の情報
    "bounds": {
        "_southWest": {
            "lat": 42.98480928720575,
            "lng": 141.54598474502566
        },
        "_northEast": {
            "lat": 42.9973656264358,
            "lng": 141.56315088272098
        }
    },
    
    # 地図のズームレベル
    "zoom": 16,
    
    # 最後に描かれた円の半径情報
    "last_circle_radius": None,
    
    # 最後に描かれた円のポリゴン情報
    "last_circle_polygon": None,
    
    # 地図の中心座標情報
    "center": {
        "lat": 42.99108777747272,
        "lng": 141.55456781387332
    }
}
```
</div></details>

</div></details>
<details><summary>図形描画</summary><div>

![](https://imgur.com/BM6nTCz.png)
- ライン・長方形・多角形・サークルを描画することができる。


</div></details>

<details><summary>通過人数カウント</summary><div>

![](https://imgur.com/8486xZJ.png)

- ユーザー別の移動の軌跡を描画できる。
- ポップアップでユーザーIDを確認できる。
- 図形に対する当たり判定を行うことができる（図形を通過する人数の表示）。


</div></details>
</div></details>

アプリリンク：https://cistfas.streamlit.app/  
アプリリンク2（定期実行しているのですぐ動く）：https://cist-fass.onrender.com/

# 終わりに

簡単に制作物を作りたい方にStreamlitはオススメです！