Streamlitで子ども向けのミニアプリを作る

## はじめに
Streamlitの勉強も兼ねて、子ども向けのアプリを作ってみました。

https://ppspps824-sz-app-app-pgy68d.streamlit.app/

## コンセプト
とにかく子どもは飽きやすいので以下のことを意識しました。
- いろんなミニアプリを作って切り替えられるようにする
- 内容も差し替えや追加を簡単にできるようにする
- イラストや効果音、アニメーションを多用する

一方で子ども向けなので、手の込んだ内容にする必要はなく、以下のような簡単な機能でとりあえずスタートさせてみます。
- 写真が撮れてアルバムのように見れる
- 音声を録音して再生できる
- お絵描きができる
- ちょっとしたクイズ


## 画像や音声ファイルの配置
- 方法1
GoogleDriveに配置して共有リンクを指定(アドレスを修正する必要あり)

https://qiita.com/rot-z/items/299ac40361690c51ce1d

- 方法2
Streamlit Cloudに直接配置して指定(今回はこちらにしました。)

以下のように配置して相対パス(./assets/image.jpgなど)で指定可能です。
```bash:ディレクトリ構成
.
|_assets
| |_image.jpg
| |_audio.mp3
|_app.py
```

## 写真を撮ってアルバムのように表示
カメラ機能は```st.camera_input```で実装可能です。

```python:python
import streamlit as st

picture = st.camera_input("Take a picture")

if picture:
    st.image(picture)
```

https://docs.streamlit.io/library/api-reference/widgets/st.camera_input

アルバム表示については当初、st.columnsを利用してページ下部にグリッド配置するようにしていましたが、サイドバーにすぐ出てくるのがウケたので変更しました)
```python:python
def photo():

    # 各種メニューの非表示設定
    hide_menu_style = """
            <style>
            #MainMenu {visibility: hidden; }
            footer {visibility: hidden;}
            </style>
            """
    st.markdown(hide_menu_style, unsafe_allow_html=True)

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

    picture = False
    picture = st.camera_input(" ")
    if picture:
        st.session_state.pictures.append(picture)

    with st.sidebar:
        st.write("# 📸撮った写真")
        if st.session_state.pictures:
            for picture in st.session_state.pictures:
                st.image(picture)
```

## 音声を録音して再生
```audio_recorder_streamlit```を利用します。

```bash:bash
pip install audio_recorder_streamlit
```

```python:audio_recorder.py
import streamlit as st
from audio_recorder_streamlit import audio_recorder


def audio_recorder():
    #  初期化
    if "audio_list" not in st.session_state:
        st.session_state.audio_list = []

    audio_bytes = False
    audio_bytes = audio_recorder()
    if audio_bytes:
        st.session_state.audio_list.append(audio_bytes)

    with st.sidebar:
        st.write("# 🎙️録った音")
        if st.session_state.audio_list:

            for audio_bytes in st.session_state.audio_list:
                st.audio(audio_bytes, format="audio/wav")
```

https://docs.streamlit.io/library/api-reference/media/st.audio


一覧の表示は写真と同じにしています。

## お絵描き
```streamlit-drawable-canvas```を使います。

```bash:bash
pip install streamlit-drawable-canvas
```

```python:python
def paint():
    def save_paint():
        st.session_state.paint_list.append(canvas_result.image_data)

    # 各種メニューの非表示設定
    hide_menu_style = """
            <style>
            #MainMenu {visibility: hidden; }
            footer {visibility: hidden;}
            </style>
            """
    st.markdown(hide_menu_style, unsafe_allow_html=True)

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

    # Specify canvas parameters in application
    drawing_mode = st.sidebar.selectbox("Drawing tool:", ("freedraw", "transform"))

    stroke_width = st.sidebar.slider("Stroke width: ", 1, 25, 3)
    if drawing_mode == "point":
        point_display_radius = st.sidebar.slider("Point display radius: ", 1, 25, 3)
    stroke_color = st.sidebar.color_picker("Stroke color hex: ")
    bg_color = st.sidebar.color_picker("Background color hex: ", "#eee")
    bg_image = st.sidebar.file_uploader("Background image:", type=["png", "jpg"])

    realtime_update = st.sidebar.checkbox("Update in realtime", True)

    canvas_result = st_canvas(
        fill_color="rgba(255, 165, 0, 0.3)",  # Fixed fill color with some opacity
        stroke_width=stroke_width,
        stroke_color=stroke_color,
        background_color=bg_color,
        background_image=Image.open(bg_image) if bg_image else None,
        update_streamlit=realtime_update,
        height=400,
        drawing_mode=drawing_mode,
        point_display_radius=point_display_radius if drawing_mode == "point" else 0,
        key="canvas",
    )

    st.button("保存", on_click=save_paint)

    with st.sidebar:
        st.write("# 🎨描いた絵")
        for paint in st.session_state.paint_list:
            st.image(paint)

```

https://github.com/andfanilo/streamlit-drawable-canvas

## ちょっとしたクイズ
2人子どもがいるのですが、お互いの小さい頃(1ヶ月〜6ヶ月ぐらい)の写真を表示してどちらが自分(姉)かを選択するものにしました。
※サンプルではどっちがにゃんこでしょう。に差し替えています。

```python:python
import base64
import glob
import random
import time

import streamlit as st
from st_clickable_images import clickable_images


def quiz():
    container = st.empty()

    def judge(clicked, correct, page_id):
        if clicked == correct:
            time.sleep(0.5)
            st.markdown(st.session_state.correct_sound_html, unsafe_allow_html=True)
            st.balloons()
            st.session_state.correct_num += 1
        else:
            time.sleep(0.5)
            st.markdown(st.session_state.failed_sound_html, unsafe_allow_html=True)

        st.session_state.page_id = page_id
        st.session_state.first = True

    def quest(no):
        st.markdown(
            f"<h1 style='text-align: center;'>第{no}問</h1>",
            unsafe_allow_html=True,
        )

        if st.session_state.first:
            st.session_state.questions = [0] * 2
            st.session_state.correct, st.session_state.failed = random.sample([0, 1], 2)
            st.session_state.questions[st.session_state.correct] = random.choice(
                st.session_state.siz_images
            )
            st.session_state.questions[st.session_state.failed] = random.choice(
                st.session_state.ao_images
            )
            st.session_state.first = False

        clicked = clickable_images(
            st.session_state.questions,
            titles=[f"Image#{str(i)}" for i in range(2)],
            div_style={
                "display": "flex",
                "justify-content": "center",
                "flex-wrap": "wrap",
            },
            img_style={"margin": "5px", "height": "400px"},
            key=no,
        )

        if clicked > -1:
            if no != st.session_state.quest_num:
                judge(clicked, st.session_state.correct, f"page{no+1}")
            else:
                judge(clicked, st.session_state.correct, "final")
        else:
            st.stop()

    if "page_id" not in st.session_state:
        siz_files = glob.glob("./assets/image/siz/*")
        ao_files = glob.glob("./assets/image/ao/*")
        start_image = "./assets/image/start.png"
        again_image = "./assets/image/again.jpg"

        st.session_state.sounds = {
            "correct": "./assets/audio/correct.mp3",
            "failed": "./assets/audio/failed.mp3",
        }
        with open(st.session_state.sounds["correct"], "rb") as file1:
            audio_str = "data:audio/ogg;base64,%s" % (
                base64.b64encode(file1.read()).decode()
            )
            st.session_state.correct_sound_html = (
                """
                        <audio autoplay=True>
                        <source src="%s" type="audio/ogg" autoplay=True>
                        Your browser does not support the audio element.
                        </audio>
                    """
                % audio_str
            )

        with open(st.session_state.sounds["failed"], "rb") as file2:
            audio_str = "data:audio/ogg;base64,%s" % (
                base64.b64encode(file2.read()).decode()
            )
            st.session_state.failed_sound_html = (
                """
                        <audio autoplay=True>
                        <source src="%s" type="audio/ogg" autoplay=True>
                        Your browser does not support the audio element.
                        </audio>
                    """
                % audio_str
            )

        st.session_state.siz_images = []
        for file1 in siz_files:
            with open(file1, "rb") as image1:
                encoded = base64.b64encode(image1.read()).decode()
                st.session_state.siz_images.append(f"data:image/jpeg;base64,{encoded}")

        st.session_state.ao_images = []
        for file2 in ao_files:
            with open(file2, "rb") as image2:
                encoded = base64.b64encode(image2.read()).decode()
                st.session_state.ao_images.append(f"data:image/jpeg;base64,{encoded}")

        with open(start_image, "rb") as image3:
            encoded = base64.b64encode(image3.read()).decode()
            st.session_state.start_logo = f"data:image/jpeg;base64,{encoded}"

        with open(again_image, "rb") as image4:
            encoded = base64.b64encode(image4.read()).decode()
            st.session_state.again_logo = f"data:image/jpeg;base64,{encoded}"

        st.session_state.correct_num = 0
        st.session_state.first = True
        st.session_state.page_id = "main"

    if st.session_state.page_id == "main":
        with container.container():
            st.markdown(
                "<h1 style='text-align: center;'>どっちがにゃんこでしょう？？</h1>",
                unsafe_allow_html=True,
            )
            st.markdown(
                "<h3 style='text-align: center;'>問題数</h3>",
                unsafe_allow_html=True,
            )
            _, _, center, _, _ = st.columns(5)
            with center:
                st.session_state.quest_num = int(
                    st.radio(" ", ("3", "5", "10"), horizontal=True)
                )
            clicked = clickable_images(
                [st.session_state.start_logo],
                titles=[f"Image#{str(i)}" for i in range(1)],
                div_style={
                    "display": "flex",
                    "justify-content": "center",
                    "flex-wrap": "wrap",
                },
                img_style={"margin": "5px", "height": "200px"},
                key="top",
            )

            if clicked == 0:
                st.session_state.page_id = "page1"
                st.experimental_rerun()

    for num in range(st.session_state.quest_num):
        if st.session_state.page_id == f"page{num+1}":
            with container.container():
                quest(num + 1)

    if st.session_state.page_id == "final":
        with container.container():
            st.markdown(
                "<h1 style='text-align: center;'>結果</h1>",
                unsafe_allow_html=True,
            )
            time.sleep(1)
            st.markdown(
                f"<h1 style='text-align: center;'>{st.session_state.correct_num}問正解！！</h1>",
                unsafe_allow_html=True,
            )
            clicked = clickable_images(
                [st.session_state.again_logo],
                titles=[f"Image#{str(i)}" for i in range(1)],
                div_style={
                    "display": "flex",
                    "justify-content": "center",
                    "flex-wrap": "wrap",
                },
                img_style={"margin": "5px", "height": "200px"},
                key="final",
            )

            if clicked == 0:
                st.session_state.pop("page_id")
                st.experimental_rerun()

```


## メニューバーをつける

サイドバーよりページ上部にメニューがあるほうが選びやすいと思ったので、```streamlit-option-menu```を使います。

```bash:bash
pip install streamlit-option-menu
```

https://github.com/victoryhb/streamlit-option-menu


## 画像のボタンを作る

標準のボタンだと味気ないので画像をボタンにします。

```st-clickable-images```を使います。

```bash:bash
pip install st-clickable-images
```

## app.py

各アプリをメニューバーをもちいて切り替えるメインの画面です。

```python:app.py
import streamlit as st
from audio import audio
from paint import paint
from photo import photo
from quiz import quiz
from streamlit_option_menu import option_menu

st.set_page_config(
    page_title="KidsApps",
    layout="wide",
    initial_sidebar_state="expanded",
)


# 各種メニューの非表示設定
hide_menu_style = """
        <style>
        #MainMenu {visibility: hidden; }
        footer {visibility: hidden;}
        </style>
        """
st.markdown(hide_menu_style, unsafe_allow_html=True)


selected = option_menu(
    None,
    ["写真", "録音", "お絵描き", "クイズ"],
    icons=["camera", "mic", "palette", "question-circle"],
    menu_icon="cast",
    default_index=0,
    orientation="horizontal",
)

if selected == "写真":
    photo()

if selected == "録音":
    audio()

if selected == "お絵描き":
    paint()

if selected == "クイズ":
    quiz()

```



## Streamlit Cloudへ公開する

おおまかな流れは以下記事と同じなので省略します。

https://qiita.com/papasim824/items/5910fdd462163c4012fc

### requirements.txtの作成
追加でインストールしたライブラリ等はrequirements.txtにまとめておくことでインストールしてくれます。

``` bash:bash
pip freeze > requirements.txt
```

## タブレットからアクセスする
普段Fire HD8 Plusでしま〇じろうを嗜んでおられるので、ホーム画面にアプリのブックマークを配置したかったのですが、どうやらできない模様。。

https://goodlifewithkids.com/firehd10-desktop-shortcut-2968

しかたなく、シルクブラウザのお気に入りに登録して初期画面に表示するという方法をとりました。

## 感想

StreamlitはAIや機械学習を活用したWebアプリ作成で人気があると思いますが、簡易なフロントエンド作成にとても便利なのでオススメです。