📚

Pythonで2匹の愛猫達の健康管理アプリを作成してみた

2024/12/18に公開

はじめに

私は現在、2匹の愛猫達と暮らしています。
彼らはそれぞれ猫種や年齢、性格も違うため、食べる餌の種類や量、餌をあげる方法やタイミングも異なります。

個体差があり、給餌方法に違いがある為、日々の体調の変化に合わせて、健康状態もそれぞれ記録する必要があります。

今回このアプリでは、どちらの猫の健康状態も正確に記録し、通院が必要な際に、早めに対処出来る様にするために、食事や体重の変化だけでなく、気になる体調の変化や病状も猫ごとに記録・確認出来る様にしました。

解決したい社会課題

< ペットの健康管理と早期医療対策の不足に関して >

近年、ペットの家族化が進み、多くの人々が犬や猫を大切な家族の一員として迎えています。しかし、ペットの健康管理は人間の健康管理と比べて体系化が進んでおらず、特に個体差に応じたきめ細やかな健康記録や早期医療対応が難しいという現状があります。

猫は体調不良を隠す可能性があり、飼い主が異変に気付いたときには病状が進行しているケースが少なくありません。また、多頭飼いの場合、猫ごとに食事量や健康状態を把握し管理することが難しく、個体ごとのケアが不十分になることが考えられます。

こうした問題を解決するため、猫ごとの食事内容、体重、健康状態を日々記録し、体調の変化を見える化するアプリが重要な役割を果たします。体重や食事量の変化、気になる症状を発見・記録することで、病気の早期発見・早期治療が可能となり、ペットの健康寿命の延伸につながります。

今回のアプリは、ペット医療の早期対策、そしてペットのQOL(生活の質)向上という社会的課題を解決する一助を目的としました。

実行環境

パソコン:Windows 10
開発環境:VSCode
言語:Python

アプリ構成

  1. メインページ(愛猫2匹の写真と体重推移グラフ)
  2. 入力フォームページ (給餌、給水記録)
  3. 入力フォームページ (健康状態記録)
  4. グラフと表のページ (給餌と体重記録のグラフと表)

実行したコード

  1. メインページ(愛猫2匹の写真と体重推移グラフ)

グラフは可動、拡大縮小、範囲の設定をその場で可能にしました。

import streamlit as st
from PIL import Image
from datetime import datetime
import pandas as pd
import plotly.graph_objects as go

st.markdown(
    """
    <style>
    .stApp {
        background-color: #8a2be2;
    }
    .stApp * {
        color: #ff00ff;
    }
    </style>
    """,
    unsafe_allow_html=True
)


#title_caption
st.title('My cats\' Health Check app')
st.caption('This is my test app')


st.write("--------------------------------------------------------------------------------------------------------------------------------------------------")
st.title("cat 1_cat 2_BW graph")

#cat1_cat2_birthday
cat1_birth_date = datetime(yyyy, mm, dd)
cat2_birth_date = datetime(yyyy, mm, dd)

#date
today = datetime.today()

def calculate_age(birth_date):
    delta = today - birth_date
    years = delta.days // 365
    months = (delta.days % 365) // 30
    days = (delta.days % 365) % 30
    return years, months, days

#age_cat1_cat2
cat1_age_years, cat1_age_months, cat1_age_days = calculate_age(cat1_birth_date)
cat2_age_years, cat2_age_months, cat2_age_days = calculate_age(cat2_birth_date)

cat1_age = f"{cat1_age_years}y {cat1_age_months}m {cat1_age_days}d"
cat2_age = f"{cat2_age_years}y {cat2_age_months}m {cat2_age_days}d"

#image_resize
image1 = Image.open('.jpg')
image2 = Image.open('.jpg')
image1_resized = image1.resize((200, 150))
image2_resized = image2.resize((200, 150))

#image_col
col1, col2 = st.columns(2)

with col1:
    st.image(image1_resized, caption=f'cat1: {cat1_age}', use_column_width=True)

with col2:
    st.image(image2_resized, caption=f'cat2: {cat2_age}', use_column_width=True)

# CSV
cat1_df = pd.read_csv('health_data/cat1_health_data.csv')
cat2_df = pd.read_csv('health_data/cat2_health_data.csv')

# graph
with col1:
    fig1 = go.Figure()

    
    fig1.add_trace(go.Scatter(x=cat1_df['date'], y=cat1_df['weight'], mode='lines+markers', name='Cat1', line=dict(color='deepskyblue')))

    for date in cat1_df['date']:
        fig1.add_shape(
            type="line",
            x0=date, x1=date, 
            y0=0, y1=8, 
            line=dict(color='gray', width=1, dash='dot') 
        )

    fig1.update_layout(
        title=dict(text="cat1_BW", font=dict(size=20, color='white')),
        xaxis=dict(
            title=dict(text="Date", font=dict(size=14, color='white')),
            rangeslider=dict(visible=True),
            gridcolor='gray',  
            zerolinecolor='gray',
            color='white'
        ),
        yaxis=dict(
            title=dict(text="BW (kg)", font=dict(size=14, color='white')),
            range=[0, 8],
            gridcolor='gray',
            zerolinecolor='gray',
            color='white'
        ),
        plot_bgcolor='black',
        paper_bgcolor='black',
        font=dict(color='white')
    )

    st.plotly_chart(fig1)

with col2:
    fig2 = go.Figure()


    fig2.add_trace(go.Scatter(x=cat2_df['date'], y=cat2_df['weight'], mode='lines+markers', name='Cat2', line=dict(color='mediumpurple')))


    for date in cat2_df['date']:
        fig2.add_shape(
            type="line",
            x0=date, x1=date,
            y0=0, y1=8,
            line=dict(color='gray', width=1, dash='dot')
        )


    fig2.update_layout(
        title=dict(text="cat2_BW", font=dict(size=20, color='white')),
        xaxis=dict(
            title=dict(text="Date", font=dict(size=14, color='white')),
            rangeslider=dict(visible=True),
            gridcolor='gray',
            zerolinecolor='gray',
            color='white'
        ),
        yaxis=dict(
            title=dict(text="BW (kg)", font=dict(size=14, color='white')),
            range=[0, 8],
            gridcolor='gray',
            zerolinecolor='gray',
            color='white'
        ),
        plot_bgcolor='black',
        paper_bgcolor='black',
        font=dict(color='white')
    )

    st.plotly_chart(fig2)

  1. 入力フォームページ (給餌、給水記録)

2匹それぞれが餌の種類、タイミングが違うのでcat1かcat2のどちらのデータを入力するのかを選択できるようにしました。水は共通の為、1つにしました。単語や単位を変更し、cat1と同様にcat2とwaterのページを作成しました。長くなるのでスクリプトは途中省略してあります。

入力フォームで給餌のstartとendの日付・時間・量を選択し、ボタンを押すとそれぞれのファイル名でcsvに記録されるようにしました。csvに記録されるので、遡って過去の記録を確認することも可能です。
直近10件の記録も表示されるようにしました。

import streamlit as st
import pandas as pd
import os
from datetime import datetime, time

st.markdown(
    """
    <style>
    .stApp {
        background-color: #4169e1;
    }
    .stApp * {
        color: #daa520;
    }
    </style>
    """,
    unsafe_allow_html=True
)


# -------------------------------------------------------------------------------------------
# -------------------------------------------------------------------------------------------

data_dir = "food_water_data"
cat1_food_csv = os.path.join(data_dir, "cat1_food_data.csv")
cat2_food_csv = os.path.join(data_dir, "cat2_food_data.csv")
cat1_cat2_water_csv = os.path.join(data_dir, "cat1_cat2_water_data.csv")


os.makedirs(data_dir, exist_ok=True)


def load_data(cat_csv):
    try:
        return pd.read_csv(cat_csv)
    except FileNotFoundError:
        return pd.DataFrame()

def save_data(df, cat_csv):
    df.to_csv(cat_csv, index=False)


# -------------------------------------------------------------------------------------------
# -------------------------------------------------------------------------------------------
# Cat1_food_page
def cat1_food_page():
    st.title("Cat1_food")

    
    data = load_data(cat1_food_csv)
    st.write("Current Records (Latest 10 Entries):", data.tail(10))  # latest 10

    
    with st.form("cat1_food_form"):
        start_date = st.date_input("start_date", value=datetime.today())
        start_time = st.time_input("start_time", value=time(8, 0))
        end_date = st.date_input("end_date", value=datetime.today())
        end_time = st.time_input("end_time", value=time(12, 0))
        feed_amount = st.number_input("feed_amount (g)", value=100, min_value=1, step=1)
        comments = st.text_input("comments (e.g., treat)", value="") 
        submitted = st.form_submit_button("Record")

        if submitted:
            
            start = datetime.combine(start_date, start_time)
            end = datetime.combine(end_date, end_time)
            duration = (end - start).total_seconds() / 3600  
            new_data = pd.DataFrame({
                "start_date": [start_date], "start_time": [start_time], "end_date": [end_date],
                "end_time": [end_time], "duration": [duration], "feed_amount (g)": [feed_amount],
                "comments": [comments]  
            })
            data = pd.concat([data, new_data], ignore_index=True)
            save_data(data, cat1_food_csv)
            st.success("Record has been saved !")
# -------------------------------------------------------------------------------------------
# Cat2_foodとwaterページも同様に作成(省略)
# -------------------------------------------------------------------------------------------


st.sidebar.title("cat1_2_food and water")
page = st.sidebar.radio("Select Page", ("Cat1_food", "Cat2_food", "cat1_cat2_water"))

if page == "Cat1_food":
    cat1_food_page()
elif page == "Cat2_food":
    cat2_food_page()
elif page == "cat1_cat2_water":
    cat1_cat2_water_page()
    

  1. 入力フォームページ (健康状態記録)
    cat1かcat2かを選択し、気になる健康状態を選択(今回は'Feline Idiopathic Cystitis (FIC)', 'Hematochezia', 'Diarrhea', 'Vomiting'を選択肢にしました。)、
    体重、コメントがあれば入力し、ボタンを押すとcsvに記録されるようにしました。
    直近の10件の記録も表として表示するようにしました。

import pandas as pd
import streamlit as st
import datetime
import os

st.markdown(
    """
    <style>
    .stApp {
        background-color: #7fffd4;
    }
    .stApp * {
        color: #8b0000;
    }
    </style>
    """,
    unsafe_allow_html=True
)

st.write("--------------------------------------------------------------------------------------------------------------------------------------------------")
st.title("cat 1_cat 2_Health Data form")

# Create the 'health_data' folder if it doesn't exist
os.makedirs('health_data', exist_ok=True)

# Define paths to the CSV files for cat1 and cat2 within the 'health_data' folder
csv_file_path_cat1 = os.path.join('health_data', 'cat1_health_data.csv')
csv_file_path_cat2 = os.path.join('health_data', 'cat2_health_data.csv')

# Define birth dates for cat1 and cat2
cat1_birth_date = datetime.datetime(2016, 4, 29)
cat2_birth_date = datetime.datetime(2023, 8, 25)

# Create the form
with st.form(key='health_form'):
    # Date input
    date = st.date_input('Date', datetime.date(2024, 6, 1))

    # Radio button for cat type
    cat1_or_cat2 = st.radio('Cat1 or Cat2', ('cat1', 'cat2'))

    # Multiselect for health condition
    abnormaly_health = st.multiselect(
        'Abnormality in the health condition', 
        ('Feline Idiopathic Cystitis (FIC)', 'Hematochezia', 'Diarrhea', 'Vomiting')
    )

    # Number input for weight
    weight = st.number_input("Weight (kg)", value=4.0, min_value=1.5, max_value=8.0, step=0.1)

    # Text input for comments
    comments = st.text_input('Comments')

    # Checkbox for veterinary hospital
    veterinary_hospital = st.checkbox('Veterinary Hospital')

    # Submit and cancel buttons
    submit_btn = st.form_submit_button('Send')
    cancel_btn = st.form_submit_button('Cancel')

# If the form is submitted
if submit_btn:
    # Calculate age
    if cat1_or_cat2 == 'cat1':
        birth_date = cat1_birth_date
    else:
        birth_date = cat2_birth_date

    delta = date - birth_date.date()
    years = delta.days // 365
    months = (delta.days % 365) // 30
    days = (delta.days % 365) % 30
    age = f'{years}y {months}m {days}d'

    # Create a dictionary for the form data
    data = {
        'date': [date],
        'age': [age],
        'abnormality': [', '.join(abnormaly_health)],
        'weight': [weight],
        'comments': [comments],
        'veterinary_hospital': [veterinary_hospital]
    }

    # Convert the dictionary to a DataFrame
    df = pd.DataFrame(data)

    # Determine which CSV file to use based on the selected cat
    if cat1_or_cat2 == 'cat1':
        csv_file_path = csv_file_path_cat1
    else:
        csv_file_path = csv_file_path_cat2

    # Load the existing data from the selected CSV file
    try:
        existing_data = pd.read_csv(csv_file_path)
    except FileNotFoundError:
        existing_data = pd.DataFrame(columns=['date', 'age', 'abnormality', 'weight', 'comments', 'veterinary_hospital'])

    # Append the new data to the existing data
    updated_data = pd.concat([existing_data, df])

    # Sort the data by date
    updated_data['date'] = pd.to_datetime(updated_data['date'])
    updated_data = updated_data.sort_values(by='date')

    # Save the updated data back to the selected CSV file
    updated_data.to_csv(csv_file_path, index=False)

    st.success('Data has been recorded successfully!')

# Display the existing data for both cat1 and cat2, limited to the last 10 rows
try:
    existing_data_cat1 = pd.read_csv(csv_file_path_cat1)
    st.write("Cat1 Health Data (Last 10 Records)")
    st.write(existing_data_cat1.tail(10))  # Limit to the last 10 rows
except FileNotFoundError:
    st.write("No data available yet for Cat1.")

try:
    existing_data_cat2 = pd.read_csv(csv_file_path_cat2)
    st.write("Cat2 Health Data (Last 10 Records)")
    st.write(existing_data_cat2.tail(10))  # Limit to the last 10 rows
except FileNotFoundError:
    st.write("No data available yet for Cat2.")
  1. グラフと表のページ (給餌と体重記録のグラフと表)

cat1とcat2のそれぞれの給餌の記録と、体重の記録をグラフと表にして、表示されるようにしました。
表は直近の10件を表示しましたが、体重のグラフは全体のデータの変化が見やすいように可動式で拡大縮小出来、見たい範囲を選べるようにしました。

import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from datetime import datetime


st.markdown(
    """
    <style>
    .stApp {
        background-color: #00ffff;
    }
    .stApp * {
        color: #ff1493;
    }
    </style>
    """,
    unsafe_allow_html=True
)

# ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////


st.title("Cat Food and Health Data (Weight) Visualization")

# Cat1_Cat2_selectbox
cat_selection = st.sidebar.selectbox(
    "Select a Cat",
    options=["Cat1", "Cat2"],
    index=1,  
)


if cat_selection == "Cat1":
    food_csv_path = "food_water_data/cat1_food_data.csv"
    health_csv_path = "health_data/cat1_health_data.csv"
else:
    food_csv_path = "food_water_data/cat2_food_data.csv"
    health_csv_path = "health_data/cat2_health_data.csv"

# food_data
try:
    food_data = pd.read_csv(food_csv_path)
except FileNotFoundError:
    st.error(f"CSV file not found: {food_csv_path}")
    st.stop()


try:
    food_data["end_date"] = pd.to_datetime(food_data["end_date"], infer_datetime_format=True)
except Exception as e:
    st.error(f"Error parsing 'end_date' in food data: {e}")
    st.stop()


for col in ["duration", "feed_amount (g)"]:
    if not pd.api.types.is_numeric_dtype(food_data[col]):
        food_data[col] = pd.to_numeric(food_data[col], errors="coerce")

food_data = food_data.dropna(subset=["duration", "feed_amount (g)", "end_date"])
food_data["feed_rate"] = (food_data["feed_amount (g)"] / food_data["duration"]) * 24

# health_data
try:
    health_data = pd.read_csv(health_csv_path)
except FileNotFoundError:
    st.error(f"CSV file not found: {health_csv_path}")
    st.stop()


try:
    health_data["date"] = pd.to_datetime(health_data["date"], format="%Y-%m-%d")
except Exception as e:
    st.error(f"Error parsing 'Date' in health data: {e}")
    st.stop()

if not pd.api.types.is_numeric_dtype(health_data["weight"]):
    health_data["weight"] = pd.to_numeric(health_data["weight"], errors="coerce")

health_data = health_data.dropna(subset=["date", "weight"])

# title_cat_selection
st.write("--------------------------------------------------------------------------------------------------------------------------------------------------")
st.title(f"{cat_selection} Food Data")

# food_data_graph
food_fig = go.Figure()
food_fig.add_trace(
    go.Scatter(
        x=food_data["end_date"],
        y=food_data["feed_rate"],
        mode="lines+markers",
        marker=dict(color="yellow"),
        line=dict(color="yellow"),
        name="Feed Rate",
    )
)
food_fig.update_layout(
    title=dict(
        text=f"Feed Rate vs End Date for {cat_selection}",
        font=dict(color="white"),
    ),
    xaxis=dict(
        title="End Date",
        titlefont=dict(color="white"),
        tickfont=dict(color="white"),
        showgrid=True,
        gridcolor="gray",
    ),
    yaxis=dict(
        title="(g) / 24 h",
        titlefont=dict(color="white"),
        tickfont=dict(color="white"),
        range=[0, 250],
        showgrid=True,
        gridcolor="gray",
    ),
    paper_bgcolor="black",
    plot_bgcolor="black",
    legend=dict(
        font=dict(color="white"),
        bgcolor="black",
        bordercolor="white",
        borderwidth=1,
    ),
)
st.plotly_chart(food_fig)
st.write(f"{cat_selection} Food Data Preview (Last 10 Rows):")
st.dataframe(food_data.tail(10))

st.write("--------------------------------------------------------------------------------------------------------------------------------------------------")
st.title(f"{cat_selection} Health Data_Weight (kg)")

# health_data_graph
health_fig = go.Figure()
health_fig.add_trace(
    go.Scatter(
        x=health_data["date"],
        y=health_data["weight"],
        mode="lines+markers",
        marker=dict(color="red"),
        line=dict(color="red"),
        name="weight",
    )
)
health_fig.update_layout(
    title=dict(
        text=f"Weight vs Date for {cat_selection}",
        font=dict(color="white"),
    ),
    xaxis=dict(
        title="Date",
        titlefont=dict(color="white"),
        tickfont=dict(color="white"),
        showgrid=True,
        gridcolor="gray",
    ),
    yaxis=dict(
        title="Weight (kg)",
        titlefont=dict(color="white"),
        tickfont=dict(color="white"),
        range=[0, 8],
        showgrid=True,
        gridcolor="gray",
    ),
    paper_bgcolor="black",
    plot_bgcolor="black",
    legend=dict(
        font=dict(color="white"),
        bgcolor="black",
        bordercolor="white",
        borderwidth=1,
    ),
)
st.plotly_chart(health_fig)
st.write(f"{cat_selection} Health Data Preview (Last 10 Rows):")
st.dataframe(health_data.tail(10))

実行結果

特に理由はありませんが、ページごとに背景と文字の色を変えてみました。

  1. メインページ(愛猫2匹の写真と体重推移グラフ)

  2. 入力フォームページ (給餌、給水記録)

  3. 入力フォームページ (健康状態記録)

  4. グラフと表のページ (給餌と体重記録のグラフと表)

課題

今回は給餌給水回数やトイレ回数などは記録していませんでした。動きを感知して動画撮影、そこから前回作成した愛猫の判別モデル
https://zenn.dev/coco_ns/articles/9b3f833fba14da#はじめに
を使用して、
どちらの猫が画像に映っているか判別を行ったり、センサー設置等を行えば、より記録を自動化・詳細なデータ収集に繋がるのではないかと考えました。

動きを感知して自動録画するシステムは作成済みなので、そこから猫の骨格検知を行い、健康状態のモニタリング・行動記録を作成することにより、更に様々な事に応用できるのではないかと思いました。

まとめ

試験的に自身の愛猫用の健康管理アプリを作成してみました。

Discussion