夏までにやせたい！栄養成分表示の画像から食事管理するアプリ作ってみた

## 最近太ってきました
昔と比べて瘦せづらくなってきました…

ダイエットにおいて、食事管理はかなり重要です。
今回は、ユーザー情報や、栄養成分表示の画像から食事管理をするアプリを作成しました。

ユーザー情報を変更すると、基礎代謝量などの計算結果や1日の目標摂取量が変わります。
![output_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3682615/6cd02fcf-8741-495a-90da-6446379d409c.gif)

画像に対して栄養成分の抽出を行うと目標値までの量やグラフが表示されます。
![output_3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3682615/695ef91e-8167-4c0a-ae4f-5a1d55d14e93.gif)


## PFCバランス
PFCバランスとは、P（たんぱく質）、F（脂質）、C（炭水化物）の摂取比率のことです。

:::note info
1~49歳の理想的なPFCバランス（総カロリーに対して）は、P: 13~20%、C: 20~30%、F: 50~65%と言われています。

※ 参考文献：厚生労働省策定「日本人食事摂取基準（2020年版）」
:::



また、今回PFCの目標摂取量などの計算式は以下のサイトを参考にしています。

https://www.luft.co.jp/cgi/pfc.php

## 実装
処理の流れは以下の通りです。

1. ユーザーの入力情報を取得
1. PFC目標を計算
1. 表示
1. 画像アップロード → 抽出 → 合計 → 差分 → グラフ

<details>
<summary>コードまとめ</summary>

```python
import streamlit as st
import base64
import requests
import json
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib
import urllib3

# --- 初期設定 ---
st.title("食事管理アプリ")
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
matplotlib.rcParams['font.family'] = 'MS Gothic'

# --- ユーザー入力 ---
def get_user_input():
    st.sidebar.header("入力項目")
    height = st.sidebar.number_input("身長（cm）", 0.0, 300.0, 170.0, 0.1)
    weight = st.sidebar.number_input("体重（kg）", 0.0, 300.0, 60.0, 0.1)
    age = st.sidebar.number_input("年齢", 0, 120, 30, 1)
    gender = st.sidebar.selectbox("性別", ("男性", "女性"))

    activity_levels = {
        "ほぼ運動しない活動代謝量": 1.2,
        "軽い運動活動代謝量": 1.375,
        "中程度の運動活動代謝量": 1.55,
        "激しい運動活動代謝量": 1.725,
        "非常に激しい活動代謝量": 1.9
    }
    activity_label = st.sidebar.selectbox("活動レベル", list(activity_levels.keys()))

    goal_options = {
        "減量": 0.8,
        "現状維持": 1.0,
        "増量": 1.2
    }
    goal_label = st.sidebar.selectbox("体重の目標", list(goal_options.keys()))

    return height, weight, age, gender, activity_levels[activity_label], goal_label, goal_options[goal_label]

# --- 栄養計算 ---
def calculate_nutrition(height, weight, age, gender, activity_mult, goal_label, goal_mult):
    bmr = (
        66.47 + (13.75 * weight) + (5.0 * height) - (6.76 * age)
        if gender == "男性"
        else 665.1 + (9.56 * weight) + (1.85 * height) - (4.68 * age)
    )
    tdee = bmr * activity_mult
    target_calories = tdee * goal_mult
    p_kcal, f_kcal, c_kcal = target_calories * 0.2, target_calories * 0.3, target_calories * 0.5
    p_g, f_g, c_g = p_kcal / 4, f_kcal / 9, c_kcal / 4
    return bmr, tdee, target_calories, (p_kcal, f_kcal, c_kcal), (p_g, f_g, c_g)

# --- 表示 ---
def display_target_info(bmr, tdee, target_calories, goal_label, pfc_kcal, pfc_g):
    st.subheader("計算結果")
    st.dataframe(pd.DataFrame({
        "項目": ["基礎代謝量（BMR）", "活動代謝量（TDEE）", f"目標摂取カロリー（{goal_label}）"],
        "値 (kcal/日)": [f"{bmr:.1f}", f"{tdee:.1f}", f"{target_calories:.1f}"]
    }), hide_index=True)

    st.subheader("PFC目標摂取量")
    st.dataframe(pd.DataFrame({
        "栄養素": ["たんぱく質（P）", "脂質　　　（F）", "炭水化物　（C）"],
        "カロリー (kcal)": [f"{pfc_kcal[0]:.1f}", f"{pfc_kcal[1]:.1f}", f"{pfc_kcal[2]:.1f}"],
        "グラム (g)": [f"{pfc_g[0]:.1f}", f"{pfc_g[1]:.1f}", f"{pfc_g[2]:.1f}"]
    }), hide_index=True)

# --- API呼び出し ---
def encode_image(uploaded_file):
    return base64.b64encode(uploaded_file.read()).decode("utf-8")

def analyze_image(base64_image, api_key):
    url = "https://api.openai.com/v1/chat/completions"
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
    payload = json.dumps({
        "model": "gpt-4o",
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text", "text": "この画像の栄養成分表示から「たんぱく質、脂質、炭水化物」の各値を抽出し、```jsonなどマークダウンや説明文はなしで、数値のみをjson形式で出力しなさい。例：{\"P\": 数値, \"F\": 数値, \"C\": 数値}"},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
            ]
        }],
        "max_tokens": 1000,
    })

    for _ in range(3):
        response = requests.post(url, headers=headers, data=payload)
        if response.ok:
            try:
                return response.json()['choices'][0]['message']['content']
            except (KeyError, json.JSONDecodeError):
                return None
    return None

def parse_nutrients(data_str):
    try:
        data = json.loads(data_str)
        return float(data.get("P", 0)), float(data.get("F", 0)), float(data.get("C", 0))
    except:
        return 0.0, 0.0, 0.0

# --- グラフ表示 ---
def plot_achievement_rates(actuals, targets):
    st.subheader("達成率")
    cols = st.columns(3)
    nutrients = [
        ("たんぱく質（P）", actuals[0], targets[0], "#FFA500", "#FFE5B4"),
        ("脂質（F）", actuals[1], targets[1], "#FFD700", "#FFFACD"),
        ("炭水化物（C）", actuals[2], targets[2], "#66BB6A", "#C8E6C9"),
    ]

    for col, (name, actual, target, color1, color2) in zip(cols, nutrients):
        rate = round(actual / target * 100, 1) if target else 0
        fig, ax = plt.subplots(figsize=(3, 3))
        ax.pie(
            [rate, 100 - rate], labels=["達成", "残り"],
            autopct='%1.1f%%', startangle=90, counterclock=False,
            colors=[color1, color2], textprops={'color': "black", 'fontsize': 10}
        )
        ax.set_title(name, fontsize=12)
        ax.axis('equal')
        col.pyplot(fig)

# --- メイン処理 ---
def main():
    api_key = "your_openai_api_key"
    height, weight, age, gender, activity_mult, goal_label, goal_mult = get_user_input()
    bmr, tdee, target_calories, pfc_kcal, pfc_g = calculate_nutrition(height, weight, age, gender, activity_mult, goal_label, goal_mult)
    display_target_info(bmr, tdee, target_calories, goal_label, pfc_kcal, pfc_g)

    st.markdown("---")
    st.subheader("画像のアップロード")
    uploaded_files = st.file_uploader("画像をアップロードしてください", type=["jpg", "jpeg", "png"], accept_multiple_files=True)

    if uploaded_files and st.button("栄養成分を抽出する"):
        st.subheader("抽出結果")
        cols = st.columns(len(uploaded_files))
        total = [0.0, 0.0, 0.0]  # P, F, C

        for i, file in enumerate(uploaded_files):
            with cols[i]:
                st.image(file, use_container_width=True)
                with st.spinner("抽出中..."):
                    base64_img = encode_image(file)
                    response = analyze_image(base64_img, api_key)
                    st.markdown(response or "抽出失敗")
                    nutrients = parse_nutrients(response or "{}")
                    total = [x + y for x, y in zip(total, nutrients)]

        # 合計と差分
        labels = ["たんぱく質（P）", "脂質　　　（F）", "炭水化物　（C）"]
        kcal_per_g = [4, 9, 4]
        total_kcal = [g * k for g, k in zip(total, kcal_per_g)]
        diff_g = [t - a for t, a in zip(pfc_g, total)]
        diff_kcal = [t - a for t, a in zip(pfc_kcal, total_kcal)]

        st.subheader("合計栄養成分")
        st.dataframe(pd.DataFrame({
            "栄養素": labels,
            "カロリー (kcal)": [f"{x:.1f}" for x in total_kcal],
            "グラム (g)": [f"{x:.1f}" for x in total]
        }), hide_index=True)

        st.subheader("目標値までの量")
        st.dataframe(pd.DataFrame({
            "栄養素": labels,
            "カロリー (kcal)": [f"{x:.1f}" for x in diff_kcal],
            "グラム (g)": [f"{x:.1f}" for x in diff_g]
        }), hide_index=True)

        plot_achievement_rates(total, pfc_g)

if __name__ == "__main__":
    main()

```

</details>


### ユーザー入力の取得
サイドバーにユーザー情報を入力するUIをまとめた関数です。
* 活動レベルや目標を辞書で定義し、対応する係数を返すようにしています
* 選択肢の日本語表示と係数を分離して、UIと処理を明確にしています

```python
def get_user_input():
    st.sidebar.header("入力項目")
    height = st.sidebar.number_input("身長（cm）", 0.0, 300.0, 170.0, 0.1)
    weight = st.sidebar.number_input("体重（kg）", 0.0, 300.0, 60.0, 0.1)
    age = st.sidebar.number_input("年齢", 0, 120, 30, 1)
    gender = st.sidebar.selectbox("性別", ("男性", "女性"))

    activity_levels = {
        "ほぼ運動しない活動代謝量": 1.2,
        "軽い運動活動代謝量": 1.375,
        "中程度の運動活動代謝量": 1.55,
        "激しい運動活動代謝量": 1.725,
        "非常に激しい活動代謝量": 1.9
    }
    activity_label = st.sidebar.selectbox("活動レベル", list(activity_levels.keys()))

    goal_options = {
        "減量": 0.8,
        "現状維持": 1.0,
        "増量": 1.2
    }
    goal_label = st.sidebar.selectbox("体重の目標", list(goal_options.keys()))

    return height, weight, age, gender, activity_levels[activity_label], goal_label, goal_options[goal_label]
```


### 基礎代謝と目標PFCの計算
基礎代謝量、活動代謝量、目標摂取カロリー、PFCバランスを計算しています。
* PFCは一般的な比率（20:30:50）で計算していますが、Pを多くするなどカスタマイズしてもいいとおもいます

```python
def calculate_nutrition(height, weight, age, gender, activity_mult, goal_label, goal_mult):
    bmr = (
        66.47 + (13.75 * weight) + (5.0 * height) - (6.76 * age)
        if gender == "男性"
        else 665.1 + (9.56 * weight) + (1.85 * height) - (4.68 * age)
    )
    tdee = bmr * activity_mult
    target_calories = tdee * goal_mult
    p_kcal, f_kcal, c_kcal = target_calories * 0.2, target_calories * 0.3, target_calories * 0.5
    p_g, f_g, c_g = p_kcal / 4, f_kcal / 9, c_kcal / 4
    return bmr, tdee, target_calories, (p_kcal, f_kcal, c_kcal), (p_g, f_g, c_g)
```



### 計算結果の表示
計算された基礎代謝量・目標摂取カロリー・PFCの目標量などをDataFrameとして表示しています。

```python
def display_target_info(bmr, tdee, target_calories, goal_label, pfc_kcal, pfc_g):
    st.subheader("計算結果")
    st.dataframe(pd.DataFrame({
        "項目": ["基礎代謝量（BMR）", "活動代謝量（TDEE）", f"目標摂取カロリー（{goal_label}）"],
        "値 (kcal/日)": [f"{bmr:.1f}", f"{tdee:.1f}", f"{target_calories:.1f}"]
    }), hide_index=True)

    st.subheader("PFC目標摂取量")
    st.dataframe(pd.DataFrame({
        "栄養素": ["たんぱく質（P）", "脂質　　　（F）", "炭水化物　（C）"],
        "カロリー (kcal)": [f"{pfc_kcal[0]:.1f}", f"{pfc_kcal[1]:.1f}", f"{pfc_kcal[2]:.1f}"],
        "グラム (g)": [f"{pfc_g[0]:.1f}", f"{pfc_g[1]:.1f}", f"{pfc_g[2]:.1f}"]
    }), hide_index=True)
```


### 画像処理とAPI連携
画像をbase64に変換し、OpenAI APIにリクエストを送り、画像から栄養素（PFC）の数値をJSON形式で抽出。その後、得られたJSONを数値に変換している。
* APIレスポンスが失敗したときの例外処理も含まれています
* マークダウン記号が含まているときもあったため、プロンプトにその内容を入れています


```python
def encode_image(uploaded_file):
    return base64.b64encode(uploaded_file.read()).decode("utf-8")

def analyze_image(base64_image, api_key):
    url = "https://api.openai.com/v1/chat/completions"
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
    payload = json.dumps({
        "model": "gpt-4o",
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text", "text": "この画像の栄養成分表示から「たんぱく質、脂質、炭水化物」の各値を抽出し、```jsonなどマークダウンや説明文はなしで、数値のみをjson形式で出力しなさい。例：{\"P\": 数値, \"F\": 数値, \"C\": 数値}"},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
            ]
        }],
        "max_tokens": 500,
    })

    for _ in range(3):
        response = requests.post(url, headers=headers, data=payload)
        if response.ok:
            try:
                return response.json()['choices'][0]['message']['content']
            except (KeyError, json.JSONDecodeError):
                return None
    return None

def parse_nutrients(data_str):
    try:
        data = json.loads(data_str)
        return float(data.get("P", 0)), float(data.get("F", 0)), float(data.get("C", 0))
    except:
        return 0.0, 0.0, 0.0
```


### 抽出結果の表示と集計
アップロードされた複数の画像から抽出されたPFCをDataFrameとして表示し、合計して目標と比較しています。

```python
if uploaded_files and st.button("栄養成分を抽出する"):
    st.subheader("抽出結果")
    cols = st.columns(len(uploaded_files))
    total = [0.0, 0.0, 0.0]  # P, F, C

    for i, file in enumerate(uploaded_files):
        with cols[i]:
            st.image(file, use_container_width=True)
            with st.spinner("抽出中..."):
                base64_img = encode_image(file)
                response = analyze_image(base64_img, api_key)
                st.markdown(response or "抽出失敗")
                nutrients = parse_nutrients(response or "{}")
                total = [x + y for x, y in zip(total, nutrients)]

    # 合計と差分
    labels = ["たんぱく質（P）", "脂質　　　（F）", "炭水化物　（C）"]
    kcal_per_g = [4, 9, 4]
    total_kcal = [g * k for g, k in zip(total, kcal_per_g)]
    diff_g = [t - a for t, a in zip(pfc_g, total)]
    diff_kcal = [t - a for t, a in zip(pfc_kcal, total_kcal)]

    st.subheader("合計栄養成分")
    st.dataframe(pd.DataFrame({
        "栄養素": labels,
        "カロリー (kcal)": [f"{x:.1f}" for x in total_kcal],
        "グラム (g)": [f"{x:.1f}" for x in total]
    }), hide_index=True)

    st.subheader("目標値までの量")
    st.dataframe(pd.DataFrame({
        "栄養素": labels,
        "カロリー (kcal)": [f"{x:.1f}" for x in diff_kcal],
        "グラム (g)": [f"{x:.1f}" for x in diff_g]
    }), hide_index=True)
```


### 達成率グラフの表示
PFCそれぞれに対して接種達成率を円グラフで表示します。
* 達成率と残りの割合を見やすく色分けしています

```python
def plot_achievement_rates(actuals, targets):
    st.subheader("達成率")
    cols = st.columns(3)
    nutrients = [
        ("たんぱく質（P）", actuals[0], targets[0], "#FFA500", "#FFE5B4"),
        ("脂質（F）", actuals[1], targets[1], "#FFD700", "#FFFACD"),
        ("炭水化物（C）", actuals[2], targets[2], "#66BB6A", "#C8E6C9"),
    ]

    for col, (name, actual, target, color1, color2) in zip(cols, nutrients):
        rate = round(actual / target * 100, 1) if target else 0
        fig, ax = plt.subplots(figsize=(3, 3))
        ax.pie(
            [rate, 100 - rate], labels=["達成", "残り"],
            autopct='%1.1f%%', startangle=90, counterclock=False,
            colors=[color1, color2], textprops={'color': "black", 'fontsize': 10}
        )
        ax.set_title(name, fontsize=12)
        ax.axis('equal')
        col.pyplot(fig)
```


## まとめ
画像からPFCを抽出するのに、はじめはOCRで実装しようとしたのですが、全くうまく行きませんでした。生成AIのすごさを改めて感じています。

また、今回栄養成分表示を用いていますが、ものによっては複数入っていても1個あたりの数値が書いていたりするので、何かいい方法はないかなと考えています。