初心者向け No.004

PythonでBMIを計算するプログラムを作る(身長・体重の2入力)

BMIの計算式は1行で終わります。難しいのは身長をcmで受け取ってmに直すところと、入力欄が2つに増えたときの受け止め方です。ここを間違えると、エラーも出ないまま結果だけが1万倍ずれます。

🎯 難易度: ★☆☆ 易しい 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分 🖥️ 確認環境: Windows 11(Python 3.12.10 / Tcl/Tk 8.6.15)/Linux Mint 22.3(起動確認) 🔄 最終更新: 2026年9月10日
🧭
急いでいる人へ

計算式だけ知りたい方は「2. BMIの式は1本だけ」へ。動くコードが欲しい方は「5. 完全なソースコード(tkinter GUI版)」へ。BMIの値がどう見てもおかしい方は単位の取り違えZeroDivisionError が出ている方は該当項目に直接お進みください。

⚠️
この記事の位置づけ

ここで作るのはプログラミングの練習用サンプルです。表示される判定名は公的機関が公開している分類表をそのまま当てはめているだけで、医学的な診断ではありません。健康に関する判断は医療機関にご相談ください。

1. このページで作るもの

BMI(体格指数)の計算は、プログラミングの練習題材として繰り返し登場します。式が短く、公的機関が計算例まで公開しているので答え合わせができるのが理由です。それでいて、実用アプリの骨格である「入力を受け取る」「数値に変える」「変な値を断る」「結果を判定する」がひととおり入っています。

ただし、温度変換のような1入力のアプリとは決定的に違う点が2つあります。ひとつは入力欄が2つに増えること。もうひとつは割り算が入ることです。この2つが、後で見ていく落とし穴のほとんどを生み出します。

読み終わったときにできるようになること

  • BMIと標準体重を自分で計算し、公的機関の計算例で検算できる
  • 身長をcmで受け取ってmに直す処理を、間違えずに書ける
  • 2つの入力欄に対して同じ検査を使い回す書き方ができる
  • 0や nan のような、そのまま計算すると事故る値を計算前に止められる
  • 「画面の数字と判定が食い違う」という浮動小数点由来のズレに気づける
📌
この記事の確認環境と方針

掲載コードは Windows 11 / Python 3.12.10 / Tcl/Tk 8.6.15info patchlevel で確認)で実際に実行しています。BMIの値・判定名・エラーメッセージ・画面に見える行数は、すべてこの環境で動かして得た実測値です。分類表と計算例は厚生労働省の公開情報、言語仕様はPython公式ドキュメントとTcl/Tkのマニュアルで裏取りし、引用箇所には出典と確認日を添えています。Linux Mint 22.3(仮想マシン)でも起動して画面が表示されるところまで確認しました(実測値は Windows 11 のみです)。macOS は未検証です。

完成する画面(と、よくある見切れ)

図1は身長175cm・体重55kgを入力したときの画面です。結果欄の下のほうが見切れていることに注目してください。BMIの下には判定と標準体重が続いているのですが、ウィンドウが小さいと表示されません。並べたもう1枚は、完成コード(500×500)を Linux Mint 22.3 で起動した直後の画面です。

BMI計算アプリの実行画面。上部に青い帯で「BMI計算アプリ」、その下の「入力」枠に身長 (cm) の入力欄(175と入力済み)と体重 (kg) の入力欄(55と入力済み)、中央に「計算する」ボタン、下の「結果」枠に【BMI計算結果】として身長 175.0 cm、体重 55.0 kg、BMI 17.96 までが表示され、その先の判定と標準体重は枠に入りきらず見えていない
図1: ウィンドウが 500×400 だと、結果欄には5行しか入りません(この図は完成コード前の500×400で撮影。完成コードは500×500で10行すべて表示されます)
Linux Mint 22.3 で起動した直後のBMI計算アプリ。「入力」枠に空の身長 (cm)・体重 (kg) の入力欄、中央に「計算する」ボタン、その下に空の「結果」枠が並んでいる
実行画面(Linux Mint)

これは実測できます。結果欄の Text ウィジェットの高さを測ったところ、ウィンドウが 500×400 のときは 113ピクセル、1行の高さが 21ピクセルだったので、表示できるのは 5行です。完成コードの結果は空行を含めて10行あるので、ちょうど半分しか見えません。

ウィンドウの高さ結果欄の高さ表示できる行数10行すべて見える?
500×400113 px5行見えない
500×440153 px7行見えない
500×480193 px9行あと1行足りない
500×500213 px10行見える

そこで完成コードでは geometry("500x500") にしています。「とりあえず 500×400」で作り始めて、表示する内容が増えたのに直し忘れるのは非常によくある詰まり方なので、症状別チェックにも項目を用意しました。

このサイトのBMI関連ページとの住み分け

当サイトには身体データを扱うアプリが複数あります。このページは「計算式と入力チェックの基礎」担当です。目的に合うページから読んでください。

ページ担当する内容こんな人向け
このページ(No.004)BMIの式・cmとmの変換・2つの入力欄の検査・判定表の作り方まずBMIを正しく計算できるようになりたい
体脂肪率計算アプリ(No.049)BMIを材料にした体脂肪率の推定・Radiobutton での性別選択BMIの先の計算に進みたい
カロリー計算(No.095)CSVファイルで食品データを持つ・合計の積み上げ外部ファイルの読み込みを学びたい

入力欄が1つのアプリをまだ作ったことがなければ、先にPythonで温度変換プログラムを作るを読むと、この記事の try / except の型がすんなり入ります。tkinterでウィンドウを出すこと自体が不安な場合はtkinterで最初のウィンドウを表示するから始めてください。

2. BMIの式は1本だけ(公式サイトの例で検算)

BMIの定義はとても単純です。厚生労働省の情報サイトは、こう説明しています。

肥満度の判定には、国際的な標準指標であるBMI(Body Mass Index)=[体重(kg)]÷[身長(m)2]が用いられています

世界保健機関(WHO)も同じ式を示しています。

求めるものPythonでの書き方
BMIBMI = 体重(kg) ÷ 身長(m)2bmi = weight / height_m ** 2
標準体重標準体重(kg) = 身長(m)2 × 22ideal = 22 * height_m ** 2

標準体重の 22 にも根拠があります。同サイトは次のように述べています。

男女とも標準とされるBMIは22.0ですが、これは統計上、肥満との関連が強い糖尿病、高血圧、脂質異常症(高脂血症)に最もかかりにくい数値とされています

標準体重(理想体重)はもっとも疾病の少ないBMI22.0を基準として、標準体重(kg)=身長(m)2×22で計算された値とする

判定に使う分類表

日本で一般に使われているのは日本肥満学会の肥満度分類です。同じページに掲載されている表を、WHO基準の呼び方と並べて示します。

BMI (kg/m2)判定(日本肥満学会)WHO基準
BMI < 18.5低体重(やせ)Underweight
18.5 ≤ BMI < 25.0普通体重Normal range
25.0 ≤ BMI < 30.0肥満(1度)Pre-obese
30.0 ≤ BMI < 35.0肥満(2度)Obese class I
35.0 ≤ BMI < 40.0肥満(3度)Obese class II
40.0 ≤ BMI肥満(4度)Obese class III
ℹ️
表を読むときの注意点(アプリの説明文を書くときに効きます)

右の列を見ると分かるとおり、日本基準の「肥満(1度)」は WHO では Pre-obese(前肥満)です。WHOのファクトシートは成人について、こう述べています。

overweight is a BMI greater than or equal to 25; and obesity is a BMI greater than or equal to 30

(過体重はBMI 25以上、肥満はBMI 30以上)

つまりWHOが obesity と呼ぶのはBMI 30以上です。日本基準で「肥満(1度)」と出ても、それは国際的な obesity の定義とは一致しません。

e-ヘルスネットに掲載されたこの分類表(日本肥満学会)にも「ただし、肥満(BMI≥25.0)は、医学的に減量を要する状態とは限らない」「BMI≥35.0を高度肥満と定義する」という注が添えられています。自作アプリに文言を足すときは、こうした但し書きごと引き写すほうが誠実です。

出典: 厚生労働省 e-ヘルスネット「肥満と健康」表1 肥満度分類(日本肥満学会)(2026年8月19日確認)/WHO「Obesity and overweight」より翻訳。原文は英語(2026年8月19日確認)。日本語訳は当サイトによるもので、WHOによる翻訳ではありません。訳文の正確性についてWHOは責任を負いません。

まずは式だけ動かす

GUIの前に、式が合っていることを確かめます。次の4行をそのまま実行してください。

ターミナル / Pythonファイル
height_cm = 170
weight = 65
height_m = height_cm / 100          # cm を m に直す。この1行が本記事の主役
print(weight / height_m ** 2)       # 22.49134948096886

22.49134948096886 と出れば正しく書けています。ここまでがBMI計算プログラムの本体で、残りは全部「入力の受け取り方」と「表示の仕方」の話です。

input() だけで完結させるコマンドライン版(GUIなし)

GUIは要らない、標準入力で十分という場合はこれで完成です。GUI版の計算部分とほぼ同じことをしています。

bmi_cli.py
CATEGORIES = [
    (40.0, "肥満(4度)"),
    (35.0, "肥満(3度)"),
    (30.0, "肥満(2度)"),
    (25.0, "肥満(1度)"),
    (18.5, "普通体重"),
    (0.0, "低体重(やせ)"),
]


def judge(bmi):
    for lower, name in CATEGORIES:
        if bmi >= lower:
            return name
    raise ValueError(f"判定できないBMIです: {bmi}")


def calc(height_cm, weight):
    """身長cmと体重kgから (BMI, 判定名, 標準体重) を返す"""
    if height_cm <= 0 or weight <= 0:
        raise ValueError("身長・体重は0より大きい値にしてください")
    height_m = height_cm / 100
    bmi = round(weight / height_m ** 2, 2)
    return bmi, judge(bmi), 22.0 * height_m ** 2


if __name__ == "__main__":
    try:
        h = float(input("身長を cm で入力してください: "))
        w = float(input("体重を kg で入力してください: "))
        bmi, name, ideal = calc(h, w)
    except ValueError as e:
        print("入力値が不正です:", e)
    else:
        print(f"BMI {bmi:.2f} / 判定 {name} / 標準体重 {ideal:.1f} kg")

実行するとこうなります。

実行結果
身長を cm で入力してください: 170
体重を kg で入力してください: 65
BMI 22.49 / 判定 普通体重 / 標準体重 63.6 kg

calc() が計算だけを担当し、画面への表示を一切していない点に注目してください。この形にしておくと、GUIを付けるときに計算部分をそのまま流用できます

公式サイトの計算例で検算する

自分の式が正しいかは、答えが公表されている値と突き合わせれば分かります。前述の厚生労働省のページには、ありがたいことに具体的な計算例が載っています。

公式サイトに載っている計算例

例:A氏 身長170cm 体重75kg BMI = 75kg / (1.70m × 1.70m) = 25.95 ∴ A氏は「肥満(1度)」と判定

この1件を含めて、境界ちょうどの値なども混ぜた7件を assert で確かめます。assert間違っていたときだけ止まるので、チェックが数行で書けます。完成コード(5章)と同じフォルダに置いて実行してください。

check_bmi.py
import math

from app004 import judge_bmi

# (身長cm, 体重kg, 期待BMI, 期待判定)
CASES = [
    (170, 75, 25.95, "肥満(1度)"),      # e-ヘルスネットの計算例
    (170, 65, 22.49, "普通体重"),
    (175, 55, 17.96, "低体重(やせ)"),
    (160, 64, 25.00, "肥満(1度)"),      # 境界ちょうど
    (170, 72.25, 25.00, "肥満(1度)"),   # 境界ちょうど
    (180, 97.2, 30.00, "肥満(2度)"),    # 境界ちょうど
    (170, 63.58, 22.00, "普通体重"),      # 標準体重
]

for height_cm, weight, expected_bmi, expected_name in CASES:
    height_m = height_cm / 100
    bmi = round(weight / height_m ** 2, 2)
    name = judge_bmi(bmi)
    assert math.isclose(bmi, expected_bmi, abs_tol=0.005), (height_cm, weight, bmi)
    assert name == expected_name, (height_cm, weight, name)
    print(f"OK  {height_cm}cm {weight}kg -> BMI {bmi:.2f}  {name}")

print("すべての検算に合格しました")

手元(Windows 11 / Python 3.12.10)で実行した結果がこちらです。1行目が公式サイトの計算例と一致しています。

実行結果
OK  170cm 75kg -> BMI 25.95  肥満(1度)
OK  170cm 65kg -> BMI 22.49  普通体重
OK  175cm 55kg -> BMI 17.96  低体重(やせ)
OK  160cm 64kg -> BMI 25.00  肥満(1度)
OK  170cm 72.25kg -> BMI 25.00  肥満(1度)
OK  180cm 97.2kg -> BMI 30.00  肥満(2度)
OK  170cm 63.58kg -> BMI 22.00  普通体重
すべての検算に合格しました
⚠️
じつはこの検算、round() を外すと落ちます

4件目の「160cm 64kg」は計算上ぴったりBMI 25.0 になるはずの組み合わせですが、round() を外すと判定が「普通体重」に変わって assert が失敗します。理由は症状別チェックで実測値つきで説明します。境界ちょうどの値をテストに入れておくと、こういうズレが自動的に見つかります。

3. 身長の単位でBMIは1万倍ずれる

BMIの式で身長はメートルです。一方、人が入力するのはほぼセンチメートルです。この食い違いが、BMI計算プログラムで最も多い失敗を生みます。しかも一切エラーが出ません。

身長170cm・体重65kgを3通りの式で計算した比較図。正しい書き方は bmi = 65 / (170 / 100) ** 2 で、cmを100で割ってmにしてから2乗し、BMIは22.49、判定は普通体重。まちがい1は bmi = 65 / 170 ** 2 で100で割り忘れており、BMIは0.0022と1万分の1になり、判定は低体重(やせ)と表示されエラーは出ない。まちがい2は bmi = 65 / (1.7 / 100) ** 2 で、mで入力したのにさらに100で割っており、BMIは224913.49と1万倍になり、判定は肥満(4度)と表示されやはりエラーは出ない。図の下部には、どのまちがいも例外にならずそれらしい判定名まで表示されるのが厄介であり、式のミスは答えの分かる値で検算し、入力のミスは計算前の範囲チェックで止めるという注意書きがある
図2: 単位を1つ間違えるだけで、答えが1万倍ずれます

実際に手元で計算した値を並べます。すべて身長170cm・体重65kgの人のつもりで書いたコードです。

書き方結果のBMI表示される判定正しい値との比
65 / (170 / 100) ** 222.49134948096886普通体重正しい
65 / 170 ** 20.002249134948096886低体重(やせ)1万分の1
65 / (1.7 / 100) ** 2224913.49480968856肥満(4度)1万倍

比がちょうど 10000.0 になるのは、身長が2乗されるからです。100倍ずれた値を2乗すれば 100 × 100 = 10000 倍になります。「なんとなく桁がおかしい」ではなく「きっかり1万倍」なら、まず単位を疑ってください。

🚨
いちばん怖いのは、それらしい判定名が出てしまうこと

上の表の2行目と3行目を見てください。どちらも例外は発生せず、「低体重(やせ)」「肥満(4度)」という、それらしい日本語が表示されます。もし判定名だけを見て「まあこんなものか」と受け入れてしまうと、間違いに気づく機会が永久に来ません。数値そのものを画面に出しておくこと、そして2章のように答えの分かる値で検算することが、唯一の防波堤です。

対策1: 変数名に単位を入れる

いちばん安上がりで効果が高い対策です。height ではなく height_cmheight_m にしておけば、height_cm ** 2 と書いた瞬間に自分で気づけます。

# 単位が読み取れない書き方(後で自分が混乱する)
height = 170
bmi = weight / height ** 2        # 間違いに見えない

# 単位を名前に入れる書き方
height_cm = 170
height_m = height_cm / 100
bmi = weight / height_m ** 2      # height_cm ** 2 と書けば違和感が出る

対策2: 計算する前に「cmらしい値か」を確かめる

利用者が身長を 1.7 と入力してしまう場合もあります。人の身長をcmで表すと、どんなに小さくても30cmを下回ることはまずありません。逆にmで入力されれば、値は必ず3以下になります。この2つの範囲は重ならないので、機械的に見分けられます。

if height_cm < 30:
    raise ValueError(f"身長は cm で入力してください({height_cm:g} は m での入力に見えます)")

完成コードはこの形にしています。実際に 1.7 と入力して確かめたところ、次のメッセージが出ました。

実測したエラーメッセージ
入力値が不正です: 身長は cm で入力してください(1.7 は m での入力に見えます)

ここで {height_cm:g} と書いているのは、1.7 をそのまま 1.7 と表示するためです。{height_cm} だと 1.7 のままですが、170.0 のような値は 170.0 と出てしまいます。:g は末尾の不要な .0 を落としてくれるので、メッセージが読みやすくなります。

💡
エラーメッセージには「どう直すか」を書く

「入力値が不正です」だけでは、利用者は何をすればいいのか分かりません。入力された値そのもの期待している形式の2つをメッセージに入れると、読んだ人がその場で直せます。これはBMIに限らず、入力を受け取るプログラム全般で効く考え方です。

4. 入力欄2つを安全に数値へ変える7つの関門

ここからGUIの話です。式は合っているのに動かない、という相談のほとんどは計算ではなく入力欄から値を取り出すところで起きています。しかも入力欄が2つあるので、同じ検査を2回書くことになります。先に全体の流れを見ておきましょう。

入力欄の文字がBMIの判定名になるまでの7段階を示す流れ図。①入力欄から取り出す entry.get() は必ず文字列を返す、②全角を半角に寄せる unicodedata.normalize NFKC がないと全角の小数点が弾かれる、③数値に変える float() は読めなければ ValueError を送出する、④nan と inf を弾く math.isfinite float はこの2つを例外にせず通してしまう、⑤0以下と単位の取り違えを弾く 0を通すと ZeroDivisionError になる、⑥計算して表示する桁で丸める round は丸めないと表示と判定が食い違うことがある、⑦判定表から名前を引く judge_bmi は下限を大きい順に並べた表を上から照合する。図の下部に、つまずくのはほぼ②から⑤の間で、計算式そのものより値をふるいにかける部分のほうが行数を使うという注意書きがある
図3: 計算式は⑥の1行。それ以外はすべて値をふるいにかける工程です

get() が返すのは必ず文字列

Tcl/Tk のマニュアルは、entry ウィジェットの get について「エントリーの文字列を返す」と1行で説明しています。数字を打っても、返ってくるのは数値ではなく文字列です。手元で確認すると、確かに str でした。

value = self.entry_height.get()   # 画面に 170 と打った状態
print(repr(value), type(value))   # '170' <class 'str'>

出典: Tcl/Tk 8.6 マニュアル ttk::entryより翻訳。原文は英語(2026年8月19日確認)

float() が通すもの・弾くもの

文字列を数値に変えるのが float() です。Python公式ドキュメントは「引数が文字列の場合、10進数でなければなりません。先頭に符号または空白を含んでもかまいません。」としたうえで、「引数は NaN (not-a-number) や正または負の無限大をあらわす文字列でもかまいません。」とも書いています。後半が曲者です。

身長の入力欄に打ちそうな文字列を Python 3.12.10 で片っ端から試した結果がこちらです。

入力した文字列float() の結果備考
"170"170.0基本形
"170.5"170.5小数もそのまま通る
" 170 "170.0前後の空白は無視される
"+170"170.0符号を前に付けてよい
"1_70"170.0桁区切りのアンダースコアが使える(3.6以降)
"1.7e2"170.0指数表記も通る
"170"170.0全角数字は通る
"0"0.0通る。このあと割ると ZeroDivisionError
"-170"-170.0通る。負の身長
"nan" / "NaN"nanエラーにならない後述
"inf" / "Infinity"infエラーにならない後述
""(空欄)ValueErrorcould not convert string to float: ''
"170cm"ValueError単位を付けて打つ人はかなり多い
"170,5"ValueErrorカンマは小数点として扱われない
"170.5"ValueError全角のピリオド(.)は通らない後述

覚えておきたいのは3点です。①全角数字は通るのに全角の小数点は通らない0 と負の数は素通りするnaninf も素通りする。BMIは割り算なので、②と③は温度変換より深刻な結果を招きます。

出典: Python公式ドキュメント 組み込み関数 float()(2026年8月19日確認)

0 を通すと ZeroDivisionError になる

身長に 0 を入れると、float() は素通りし、割り算の瞬間に落ちます。Python公式ドキュメントは ZeroDivisionError を「除算や剰余演算の第二引数が 0 であった場合に送出されます。関連値は文字列で、その演算における被演算子と演算子の型を示します。」と定義しています。

>>> 60 / (0 / 100) ** 2
Traceback (most recent call last):
  ...
ZeroDivisionError: float division by zero

メッセージが float division by zero になっているのは、0 / 100 の時点で 0.0(浮動小数点数)になっているからです。メッセージは演算子と型で変わります。手元で全部試した結果がこちらです。

ZeroDivisionError のメッセージ
60 / 0division by zero
60 / 0.060.0 / 0float division by zero
60 // 0integer division or modulo by zero
60 % 0integer modulo by zero

この違いは、どこで型が変わったかを教えてくれる手がかりになります。BMI計算で float division by zero が出たなら、身長を100で割った時点ですでに浮動小数点数になっていた、と読み取れます。

出典: Python公式ドキュメント 組み込み例外 ZeroDivisionError(2026年8月19日確認)

nan と inf は float() をすり抜けて判定を壊す

ここが温度変換ともっとも違うところです。BMIは割り算なので、naninf が入ったときの壊れ方が独特です。実測しました。

身長体重計算されたBMI表示される判定何が起きているか
inf650.0低体重(やせ)無限大で割ったので0。BMI自体は有限値になる
nan65nan判定できずに例外比較がすべて偽になり、判定表を抜けて ValueError: 判定できないBMIです: nan になる
170infinf肥満(4度)inf >= 40.0 が真なので、いちばん上の区分に入ってしまう
170nannan判定できずに例外2行目と同じ。nan はどの下限とも比較が成立しない

2行目・4行目が例外になるのは、この記事の判定表が「抜けたら raise」で終わっているからです。よくある ifelif を6段つないで末尾を else にした書き方だと、同じ nan が「肥満(4度)」として表示されてしまいます。どちらが安全かは6-2で扱います。

1行目が特に厄介です。身長に inf を入れるとBMIは 0.0 という完全に有限な値になります。つまり計算結果の側で math.isfinite() を掛けても検知できません。チェックは計算後ではなく、入力を数値に変えた直後に置く必要があるということです。完成コードが parse_number() の中で math.isfinite() を呼んでいるのは、この実測結果に基づいています。

公式ドキュメントは math.isfinite(x) を「x が無限でも NaN でもない場合に True を返します。それ以外の時には False を返します。(注意: 0.0 は有限数と扱われます。)」と説明しています。かっこ書きの注意が、まさに上の1行目の話です。

出典: Python公式ドキュメント math.isfinite()(2026年8月19日確認)

失敗を受け止める型

変換が失敗しうると分かっているので、tryexcept で囲みます。GUIアプリでは、この受け止めが特に重要です。例外を放置すると、画面上は何も起きていないように見えるのに、裏側のターミナルにだけエラーが出るという一番デバッグしにくい状態になるからです。

try:
    result = self._calculate(height_str, weight_str)
except ValueError as e:
    messagebox.showerror("エラー", f"入力値が不正です: {e}")
    return
self._show_result(result)

公式ドキュメントは ValueError を「演算子や関数が、正しい型だが適切でない値を持つ引数を受け取ったときや、IndexError のようなより詳細な例外では記述できない状況で送出されます。」と定義しています。float() の失敗も、こちらが raise する範囲外エラーも、どちらも ValueError にそろえておくと except が1本で済みます。

出典: Python公式ドキュメント 組み込み例外 ValueError(2026年8月19日確認)

例外の基本的な考え方や、他のエラーメッセージの読み方はPythonエラー一覧&解決法の ValueError の章にまとめてあります。

5. 完全なソースコード(tkinter GUI版)

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)

全体です。app004.py という名前で保存して実行してください。行数の大半はレイアウトと入力チェックで、BMIを求めている行は1行だけです。まず動く画面を見てから読み進めるのが近道です。

app004.py
import math
import tkinter as tk
import unicodedata
from tkinter import ttk, messagebox

# 肥満度分類(日本肥満学会)。(BMIの下限, 判定名) を大きいほうから並べる
BMI_CATEGORIES = [
    (40.0, "肥満(4度)"),
    (35.0, "肥満(3度)"),
    (30.0, "肥満(2度)"),
    (25.0, "肥満(1度)"),
    (18.5, "普通体重"),
    (0.0, "低体重(やせ)"),
]


def parse_number(text, label):
    """入力欄の文字列を数値にする。全角を半角に寄せ、nan・inf・0以下を弾く"""
    normalized = unicodedata.normalize("NFKC", text).replace("−", "-").strip()
    value = float(normalized)          # 数値として読めなければ ValueError
    if not math.isfinite(value):       # float() は "nan" と "inf" を通してしまう
        raise ValueError(f"{label}には有限の数値を入力してください")
    if value <= 0:
        raise ValueError(f"{label}は0より大きい値を入力してください")
    return value


def judge_bmi(bmi):
    """BMI から肥満度分類の判定名を返す"""
    for lower, name in BMI_CATEGORIES:
        if bmi >= lower:
            return name
    raise ValueError(f"判定できないBMIです: {bmi}")


class App04:
    """BMI計算アプリ"""

    def __init__(self, root):
        self.root = root
        self.root.title("BMI計算アプリ")
        self.root.geometry("500x500")   # 結果は10行。400だと5行しか見えない
        self.root.minsize(500, 500)
        self.root.configure(bg="#f8f9fc")
        self._build_ui()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=12)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="BMI計算アプリ",
                 font=("Noto Sans JP", 16, "bold"),
                 bg="#3776ab", fg="white").pack()

        main_frame = tk.Frame(self.root, bg="#f8f9fc", padx=20, pady=20)
        main_frame.pack(fill=tk.BOTH, expand=True)

        input_frame = ttk.LabelFrame(main_frame, text="入力", padding=10)
        input_frame.pack(fill=tk.X, pady=(0, 10))

        tk.Label(input_frame, text="身長 (cm):").grid(row=0, column=0, sticky="w", pady=4)
        self.entry_height = ttk.Entry(input_frame, width=20, font=("Arial", 12))
        self.entry_height.grid(row=0, column=1, padx=8, sticky="ew")
        self.entry_height.bind("<Return>", lambda e: self.process())

        tk.Label(input_frame, text="体重 (kg):").grid(row=1, column=0, sticky="w", pady=4)
        self.entry_weight = ttk.Entry(input_frame, width=20, font=("Arial", 12))
        self.entry_weight.grid(row=1, column=1, padx=8, sticky="ew")
        self.entry_weight.bind("<Return>", lambda e: self.process())
        input_frame.columnconfigure(1, weight=1)

        ttk.Button(main_frame, text="計算する", command=self.process).pack(pady=8)

        result_frame = ttk.LabelFrame(main_frame, text="結果", padding=10)
        result_frame.pack(fill=tk.BOTH, expand=True)

        self.result_text = tk.Text(result_frame, font=("Noto Sans JP", 11),
                                   bg="white", relief=tk.FLAT, state=tk.DISABLED)
        self.result_text.pack(fill=tk.BOTH, expand=True)

        self.entry_height.focus_set()

    def process(self):
        height_str = self.entry_height.get().strip()
        weight_str = self.entry_weight.get().strip()
        if not height_str or not weight_str:
            messagebox.showwarning("警告", "身長と体重を入力してください")
            return
        try:
            result = self._calculate(height_str, weight_str)
        except ValueError as e:
            messagebox.showerror("エラー", f"入力値が不正です: {e}")
            return
        self._show_result(result)

    def _calculate(self, height_str, weight_str):
        height_cm = parse_number(height_str, "身長")
        weight = parse_number(weight_str, "体重")

        # cm と m を取り違えると BMI が 10000 倍ずれるので、計算する前に止める
        if height_cm < 30:
            raise ValueError(
                f"身長は cm で入力してください({height_cm:g} は m での入力に見えます)"
            )
        if height_cm > 300:
            raise ValueError("身長は 30〜300 cm の範囲で入力してください")
        if not 5 <= weight <= 500:
            raise ValueError("体重は 5〜500 kg の範囲で入力してください")

        height_m = height_cm / 100
        # 画面に出す桁で丸めてから判定する。丸めないと 24.999999999999996 が
        # 「25.00」と表示されているのに「普通体重」と判定されてしまう
        bmi = round(weight / height_m ** 2, 2)
        ideal_weight = 22.0 * height_m ** 2

        return (
            f"【BMI計算結果】\n\n"
            f"身長:     {height_cm:.1f} cm\n"
            f"体重:     {weight:.1f} kg\n"
            f"BMI:      {bmi:.2f}\n"
            f"判定:     {judge_bmi(bmi)}\n\n"
            f"標準体重: {ideal_weight:.1f} kg(BMI=22)\n\n"
            f"※学習用のサンプルです。医学的な診断ではありません。"
        )

    def _show_result(self, text):
        self.result_text.config(state=tk.NORMAL)
        self.result_text.delete("1.0", tk.END)
        self.result_text.insert("1.0", text)
        self.result_text.config(state=tk.DISABLED)


if __name__ == "__main__":
    root = tk.Tk()
    app = App04(root)
    root.mainloop()

この完成コードは minsize(500, 500) で最小サイズを固定しているので、これより小さくは縮められません。

ℹ️
クラスの外に関数を2つ置いている理由

parse_number()judge_bmi() は、画面のことを何も知りません。だから import するだけで単体で呼べます。2章check_bmi.pyfrom app004 import judge_bmi と書けているのはこのためです。「画面を触る処理」と「触らない処理」を分けておくと、あとからテストを書けます。

6. コード解説(意味のまとまりごと)

上から順に分解します。変数や関数の書き方そのものに不安があれば、Pythonの基本構文関数の使い方もあわせて確認してください。

6-1. import: 4行で何を読み込んでいるか

import math
import tkinter as tk
import unicodedata
from tkinter import ttk, messagebox

mathnaninf を弾くため、unicodedata は全角入力を半角に寄せるために使います。tkinter はGUIの本体、ttk はOSのテーマに見た目を合わせたウィジェット群、messagebox はエラーを知らせるダイアログです。すべて標準ライブラリなので追加インストールは要りません。

6-2. 判定表を先に書いておく理由

BMI_CATEGORIES = [
    (40.0, "肥満(4度)"),
    (35.0, "肥満(3度)"),
    (30.0, "肥満(2度)"),
    (25.0, "肥満(1度)"),
    (18.5, "普通体重"),
    (0.0, "低体重(やせ)"),
]


def judge_bmi(bmi):
    """BMI から肥満度分類の判定名を返す"""
    for lower, name in BMI_CATEGORIES:
        if bmi >= lower:
            return name
    raise ValueError(f"判定できないBMIです: {bmi}")

多くのサンプルは ifelif を6段つなげて書きます。それでも動きますが、表に切り出すと3つ得をします。

  1. 分類表と見比べられる: 2章の表と1対1で対応するので、数値の写し間違いをその場で見つけられます。
  2. 境界の向きが1か所で決まる: if bmi >= lower という比較が1回しか出てこないので、「18.5は低体重なのか普通体重なのか」を迷いません(答えは普通体重です)。
  3. 基準を差し替えやすい: WHO基準で分類したくなったら、リストの中身を書き換えるだけで済みます。

実際に境界の前後を通して確かめました。分類表どおりに下限の値ちょうどは上の区分に入ります

渡したBMI返ってきた判定
18.49低体重(やせ)
18.5普通体重
24.99普通体重
25.0肥満(1度)
29.99肥満(1度)
30.0肥満(2度)
39.99肥満(3度)
40.0肥満(4度)

最後の raise ValueError は、一見到達しないように見えます。しかし bminan だとすべての比較が偽になるため、ループを抜けてここに来ます。for のあとに何も書かないと None が返り、画面に「判定: None」と出てしまいます。「ありえない」と思う分岐にこそ、はっきり失敗する処理を置いてください。

6-3. parse_number(): 2つの入力欄で同じ検査を使い回す

def parse_number(text, label):
    """入力欄の文字列を数値にする。全角を半角に寄せ、nan・inf・0以下を弾く"""
    normalized = unicodedata.normalize("NFKC", text).replace("−", "-").strip()
    value = float(normalized)          # 数値として読めなければ ValueError
    if not math.isfinite(value):       # float() は "nan" と "inf" を通してしまう
        raise ValueError(f"{label}には有限の数値を入力してください")
    if value <= 0:
        raise ValueError(f"{label}は0より大きい値を入力してください")
    return value

この関数が入力欄が2つに増えたときの答えです。身長と体重で同じ検査をコピーすると、片方だけ直して不整合を起こします。関数に切り出しておけば、呼ぶ側は2行で済みます。

height_cm = parse_number(height_str, "身長")
weight = parse_number(weight_str, "体重")

label を引数で受け取っているのが小さな工夫です。これがあるので、エラーメッセージが「身長には有限の数値を入力してください」「体重は0より大きい値を入力してください」と、どちらの欄の話かまで伝えられます。入力欄が複数ある画面では、「どこが悪いのか」を言えるかどうかで使い勝手がまるで変わります。

unicodedata.normalize("NFKC", ...) は全角英数字や全角の記号を半角に寄せる標準ライブラリの関数です。.replace("−", "-") を足しているのは、数学記号のマイナス(U+2212)がNFKCでは変換されないためです。これを入れた前後で挙動を比べました。

入力正規化なし(float() に直接)正規化あり(parse_number()
"170"170.0170.0
"170.5"ValueError170.5
"170"(全角と半角の混在)170.0170.0
"−170"(U+2212のマイナス)ValueErrorValueError(0以下として弾く)
"170cm"ValueErrorValueError

2行目が正規化の成果です。日本語入力をオンにしたまま「170.5」と打つと、数字は正しいのに小数点だけが理由で弾かれていました。原因が分かりにくい失敗なので、拾えるようにしておく価値があります。

6-4. _calculate(): 単位の見張りを立ててから割る

# cm と m を取り違えると BMI が 10000 倍ずれるので、計算する前に止める
if height_cm < 30:
    raise ValueError(
        f"身長は cm で入力してください({height_cm:g} は m での入力に見えます)"
    )
if height_cm > 300:
    raise ValueError("身長は 30〜300 cm の範囲で入力してください")
if not 5 <= weight <= 500:
    raise ValueError("体重は 5〜500 kg の範囲で入力してください")

3章で見た対策をここで実装しています。not 5 <= weight <= 500 という書き方はPythonらしい連鎖比較で、5 <= weight and weight <= 500 と同じ意味です。

⚠️
このチェックでも防げないものがあります

身長欄に 65、体重欄に 170逆に入力した場合、身長65cmも体重170kgも上の範囲に収まるため、そのまま計算されて BMI 402.37 → 肥満(4度)と表示されます(実測値)。範囲チェックは万能ではありません。踏み込んだ対策は8章の実験1で扱います。

6-5. round() してから判定する理由

height_m = height_cm / 100
# 画面に出す桁で丸めてから判定する。丸めないと 24.999999999999996 が
# 「25.00」と表示されているのに「普通体重」と判定されてしまう
bmi = round(weight / height_m ** 2, 2)
ideal_weight = 22.0 * height_m ** 2

この round() は飾りではありません。外すと、画面の数字と判定が食い違います。詳しい実測は症状別チェックにありますが、要点だけ言うと、(160/100) ** 22.56 ではなく 2.5600000000000005 になるため、BMIちょうど25のはずの計算が 24.999999999999996 になり、25.00 と表示されながら「普通体重」と判定されます。

⚖️
この対処にも副作用があります(正直に書きます)

表示と判定をそろえた代わりに、本当のBMIが 24.995〜24.999 の人は「25.00 / 肥満(1度)」と表示されます。丸めた結果で判定しているからです。実測すると、24.994 は「普通体重」、24.995 からは「肥満(1度)」になりました。

どちらを選ぶかは目的次第です。画面の数字と判定が一致していることを優先するなら丸める、厳密なBMI値で分類したいなら丸めない。完成コードは「利用者から見て矛盾がないこと」を優先しました。丸めたくない場合は round() を外し、代わりに表示の桁数を増やしてください。

6-6. Entry を2つ並べて grid で配置する

tk.Label(input_frame, text="身長 (cm):").grid(row=0, column=0, sticky="w", pady=4)
self.entry_height = ttk.Entry(input_frame, width=20, font=("Arial", 12))
self.entry_height.grid(row=0, column=1, padx=8, sticky="ew")
self.entry_height.bind("<Return>", lambda e: self.process())

入力欄が2つになると pack() では縦横がそろわないので、grid() で表のように並べます。row=0 が身長の行、row=1 が体重の行、column=0 がラベル、column=1 が入力欄です。

作成と配置を必ず2行に分けてください。self.entry_height = ttk.Entry(...).grid(...) と1行にまとめると、self.entry_height には grid() の戻り値である None が入り、あとで get() を呼んだ瞬間にエラーになります(症状別チェック参照)。

sticky="ew"input_frame.columnconfigure(1, weight=1) はセットです。前者が「割り当てられた区画の左右いっぱいに広がれ」、後者が「余った横幅は1列目に配れ」という指示なので、ウィンドウを横に広げると入力欄も伸びます。

ラベルのテキストに (cm) (kg) と単位を書いてあるのも意図的です。3章の取り違えは、そもそも画面に単位が書いてあれば起きにくくなります。入力チェックを書く前に、まず画面で誤解を減らすほうが効きます。

6-7. 両方の入力欄でEnterキーを効かせる

self.entry_height.bind("<Return>", lambda e: self.process())
# ...
self.entry_weight.bind("<Return>", lambda e: self.process())
# ...
self.entry_height.focus_set()

<Return> がキーボードのEnterキーです。<Enter> は「マウスがウィジェットに乗った」というまったく別のイベントなので、名前が紛らわしい部分です。lambda e: と引数を1つ受け取っているのは、bind() で呼ばれる関数にはイベント情報のオブジェクトが必ず1つ渡されるためです。

両方の欄に割り当てているのは、どちらを打ち終わってEnterを押しても計算されるようにするためです。最後の focus_set() は起動直後のカーソル位置を身長欄に置く指示で、実測すると起動直後のフォーカスが確かに身長欄になっていました。これでマウスに触らず「身長→Tab→体重→Enter」だけで完結します

6-8. _show_result(): 編集禁止のまま書き換える

def _show_result(self, text):
    self.result_text.config(state=tk.NORMAL)
    self.result_text.delete("1.0", tk.END)
    self.result_text.insert("1.0", text)
    self.result_text.config(state=tk.DISABLED)

結果欄の Text ウィジェットは state=tk.DISABLED で作ってあり、利用者が勝手に書き換えられないようにしています。ただしこの状態ではプログラムからも書き込めません。そこで、いったん NORMAL に戻して書き換え、すぐ DISABLED に戻す、という手順を踏んでいます。この4行はセットで覚えてしまってください。

"1.0" は Text ウィジェット独特の位置指定で、「1行目の0文字目」つまり先頭を意味します。行番号が1始まり、文字位置が0始まりという組み合わせなので、最初は戸惑うところです。

7. 式1本から作る7ステップ

いきなり全体を写経すると、どこで失敗したのか分からなくなります。まず計算だけを print() で確かめ、そのあとで画面を付ける順番がおすすめです。各ステップで必ず実行して、動くことを確認してから次に進んでください。

  1. 1
    計算だけを関数にする

    GUIのことは忘れて、まず計算関数だけを書きます。2章の公式サイトの例(170cm・75kg → 25.95)で答え合わせをしてください。

    def bmi(height_cm, weight):
        return weight / (height_cm / 100) ** 2
    
    
    print(bmi(170, 75))   # 25.95155709342561
    print(bmi(170, 65))   # 22.49134948096886
  2. 2
    判定を足す

    分類表をリストにして、上から照合します。境界の 18.525.0 を必ず試してください。

    CATEGORIES = [(40.0, "肥満(4度)"), (35.0, "肥満(3度)"), (30.0, "肥満(2度)"),
                  (25.0, "肥満(1度)"), (18.5, "普通体重"), (0.0, "低体重(やせ)")]
    
    
    def judge(bmi):
        for lower, name in CATEGORIES:
            if bmi >= lower:
                return name
    
    
    print(judge(18.5))   # 普通体重
    print(judge(25.0))   # 肥満(1度)
  3. 3
    空のウィンドウを出す

    新しいファイルを app004.py という名前で保存し、この3行で灰色のウィンドウが出ることを確認します。ここが動かない場合は環境側の問題なので、tkinterが使えるかの確認手順に戻ってください。

    import tkinter as tk
    
    root = tk.Tk()
    root.mainloop()
  4. 4
    入力欄を2つとボタンを置く

    root = tk.Tk()root.mainloop()に書き足します。以降のステップもすべてこの間に追加していきます。grid()row を変えるだけで2段になります。

    from tkinter import ttk
    
    ttk.Label(root, text="身長 (cm):").grid(row=0, column=0)
    entry_h = ttk.Entry(root, width=20)
    entry_h.grid(row=0, column=1)
    
    ttk.Label(root, text="体重 (kg):").grid(row=1, column=0)
    entry_w = ttk.Entry(root, width=20)
    entry_w.grid(row=1, column=1)
    
    ttk.Button(root, text="計算する", command=lambda: print("clicked!")).grid(row=2, column=1)
  5. 5
    2つの値を取り出して型を見る

    ボタンの処理を関数に変え、両方の欄から取り出せているか print() で確認します。type() も一緒に出すと、返ってくるのが文字列だと実感できます。

    def on_click():
        print(repr(entry_h.get()), type(entry_h.get()))
        print(repr(entry_w.get()), type(entry_w.get()))
    
    
    ttk.Button(root, text="計算する", command=on_click).grid(row=2, column=1)
  6. 6
    計算して画面に出し、わざと壊してみる

    結果表示用のラベルを足して、そこに書き込みます。動いたら身長欄に「あ」と入れてボタンを押し、次に「0」を入れて押してください。画面には何も起きず、ターミナルにだけ ValueErrorZeroDivisionError が出るはずです。これがGUIアプリのエラーの見え方です。

    result_label = ttk.Label(root, text="")
    result_label.grid(row=3, column=0, columnspan=2)
    
    
    def on_click():
        h = float(entry_h.get())
        w = float(entry_w.get())
        result_label.config(text=f"BMI {w / (h / 100) ** 2:.2f}")
  7. 7
    検査を関数にまとめて完成形に寄せる

    ステップ6で見た2つの失敗を、parse_number() にまとめて受け止めます。ここまで来たら5章の完成コードと見比べて、クラス化・レイアウト・単位チェックを取り込んでください。

    from tkinter import messagebox
    
    
    def on_click():
        try:
            h = parse_number(entry_h.get(), "身長")
            w = parse_number(entry_w.get(), "体重")
        except ValueError as e:
            messagebox.showerror("エラー", f"入力値が不正です: {e}")
            return
        result_label.config(text=f"BMI {round(w / (h / 100) ** 2, 2):.2f}")
💡
エディタの設定でつまずいたら

VSCodeで実行ボタンが出ない・Pythonが選べないといった場合は、VSCodeのPython環境セットアップで拡張機能とインタープリターの設定を確認してください。

8. 自分でいじって試す5つの実験

動いたら、次は意図的に書き換えて結果を見る番です。どれも数分で終わります。1か所ずつ変えて実行すると、「この行が結果のここに効いている」という感覚が短時間で身につきます。

実験1: 身長と体重の入れ替えを見抜く

6章で触れたとおり、身長65・体重170と逆に入れても範囲チェックは通ってしまいます。完全に見分けることはできませんが、「まずありえない組み合わせ」を警告することはできます。

def looks_swapped(height_cm, weight):
    """身長より体重のほうが大きい、かつ身長が低すぎる組み合わせを疑う"""
    return height_cm < 120 and weight > height_cm


# 使う側
if looks_swapped(height_cm, weight):
    raise ValueError(
        f"身長と体重が入れ替わっていませんか?"
        f"(身長 {height_cm:g} cm / 体重 {weight:g} kg)"
    )

身長を120cm未満に限っているのは、身長170cm・体重180kgのような実在しうる組み合わせを誤って弾かないためです。入力チェックは厳しくすればよいというものではなく、正当な利用者を締め出さない範囲で組み立てる必要があります。ここが設計の腕の見せどころです。

なお、この判定でも身長110cm・体重105kgのような値は通ります。入力チェックで100%防ぐのは無理だと割り切り、結果の数値そのものを画面に出して利用者が気づけるようにしておくほうが現実的です。

実験2: cmとmのどちらでも受け付ける

弾くのではなく、受け入れてしまう方針もあります。3章で見たとおり、cmの値とmの値は範囲が重ならないので自動判別できます。

def to_cm(value):
    """3以下ならメートル入力とみなして100倍する"""
    if value <= 3:
        return value * 100
    return value


print(to_cm(1.7))    # 170.0
print(to_cm(170))    # 170
print(to_cm(1.75))   # 175.0

親切ですが、おすすめはしません。利用者は自分の入力が勝手に解釈されたことに気づけず、次に別のアプリを使ったときに同じ間違いを繰り返すからです。断って理由を伝えるほうが、長い目で見て利用者のためになります。どうしても自動判別したい場合は、結果に「1.7 m を 170 cm として計算しました」と明記してください。

実験3: 目標体重までの差を出す

標準体重は表示していますが、「あと何kg」が分かるともう一歩使えます。1行足すだけです。

diff = weight - ideal_weight
print(f"標準体重との差: {diff:+.1f} kg")   # 例: +1.4 kg / -8.6 kg

:+.1f+ が要点です。これを付けるとプラスのときも符号が出るので、増減の向きがひと目で分かります。付けないと 1.4-8.6 になり、増えているのか減っているのか読み取りにくくなります。

身長170cm・体重65kgで試すと、標準体重は 63.6 kg なので +1.4 kg と出ます。

実験4: 数字以外を打てないようにする

エラーを出すのではなく、そもそも入力させない方法です。Tcl/Tk の entry には検証の仕組みがあり、Pythonからは root.register() で関数を登録して使います。

def only_number(new_value):
    """入力途中の状態も許す、ゆるめの判定"""
    if new_value in ("", "."):        # float() が読めない「入力途中」はこの2つだけ
        return True
    try:
        float(new_value)
    except ValueError:
        return False
    return True


vcmd = (root.register(only_number), "%P")
entry = ttk.Entry(root, validate="key", validatecommand=vcmd)
entry.pack()

validate="key" はキーを打つたびに検証するモードです。"%P" は Tcl/Tk が用意している置換コードで、マニュアルには「事前検証では、編集が受け入れられた場合のエントリーの新しい値。再検証では、エントリーの現在の値。」と説明されています。つまり only_number() には「その1文字を打ったあとの文字列」が渡され、False を返すと入力が拒否されます。

最初の if で逃がしているのは ""(全消去した瞬間)と "." の2つだけです。実際に試すと "1.""0."float() がそのまま読めたので、特別扱いは要りませんでした。「入力途中で一時的に数値にならない文字列」を必要最小限だけ許すのがコツです。ここを広げすぎると検証の意味がなくなります。

身長・体重はどちらも正の数なので、温度と違ってマイナス記号を許す必要がありません。そのぶん判定は簡単になります。ただし float() で判定しているため "nan""inf" はこの検証を通ります。最終的なチェックはやはり parse_number() 側で必要です。

出典: Tcl/Tk 8.6 マニュアル ttk::entry(VALIDATION SCRIPT SUBSTITUTIONS)より翻訳。原文は英語(2026年8月19日確認)

実験5: 判定によって文字色を変える

結果が文字だけだと読み飛ばされます。Text ウィジェットのタグを使うと、一部分だけ色を変えられます。

JUDGE_COLORS = {
    "低体重(やせ)": "#2563eb",
    "普通体重": "#16a34a",
    "肥満(1度)": "#d97706",
    "肥満(2度)": "#dc2626",
    "肥満(3度)": "#dc2626",
    "肥満(4度)": "#dc2626",
}

# _build_ui() の中で、色ごとにタグを用意しておく
for name, color in JUDGE_COLORS.items():
    self.result_text.tag_configure(name, foreground=color, font=("Noto Sans JP", 11, "bold"))

# _show_result() で、判定名の行だけタグを付ける
self.result_text.insert(tk.END, judge_name, judge_name)

これは _show_result() の中で使う前提の断片で、そのまま貼っても動きません。完成コードの _show_result(self, text) は組み立て済みの全文を1回で入れるだけなので、judge_name という変数がそこに存在しないからです。_calculate() の戻り値を「本文」と「判定名」の2つに分け、_show_result(self, text, judge_name) のように判定名を引数で受け取る形に変えてください。そのうえで、stateNORMAL に戻している間に判定名の前・判定名・判定名の後の3回に分けて insert() し、最後に DISABLED へ戻します。

tag_configure() で「この名前のタグはこの見た目」と登録し、insert() の3番目の引数にタグ名を渡すと、その文字だけに適用されます。辞書のキーを判定名そのものにしておくと、タグ名を別に考えなくて済みます。

色を選ぶときは、色だけに意味を持たせないでください。色が区別できない利用者にも伝わるよう、判定名という文字が必ず一緒に表示されるようにしておきます。上のコードは文字に色を付けているだけなので、この条件を満たしています。

9. うまく動かないときの症状別チェック

BMI計算は「エラーが出るパターン」より「エラーは出ないのに結果がおかしいパターン」のほうが多い題材です。まず症状から当たりを付けてください。

症状まず疑うところ
BMIが0.002や22万になるcmとmの取り違え
ZeroDivisionError: float division by zero身長に0が入っている
could not convert string to float空欄・単位付き入力・カンマ
日本語入力のまま打つと弾かれる全角の小数点(.)
表示は25.00なのに判定が「普通体重」浮動小数点の丸め誤差
naninf で変な判定が出るfloat() はこの2つを通す
身長と体重を逆に入れても計算される範囲チェックの限界
判定や標準体重が画面に出ないウィンドウが小さい
'NoneType' object has no attribute 'get'作成と配置を1行にまとめている
Enterキーで反応しない<Return><Enter> の取り違え

❌ BMIが0.002や22万になる(単位の取り違え)

原因: 身長のcmとmを取り違えています。エラーは一切出ません。実測した3パターンは3章の表のとおりで、正しい値との比はきっかり10000倍または1万分の1になります。

対処: まず print()height_m の値を見てください。170cmの人なら 1.7 になっているはずです。170.0 なら100で割り忘れ、0.017 なら割りすぎです。

height_m = height_cm / 100
print(f"{height_cm=} {height_m=}")   # height_cm=170.0 height_m=1.7

f文字列の {変数名=} という書き方(3.8以降)は、変数名と値を同時に出せるのでデバッグに便利です。恒久的な対策は変数名に単位を入れることと、2章assert による検算です。

❌ ZeroDivisionError: float division by zero

原因: 身長に 0 が入ったまま割り算に進んでいます。float("0") は成功するので、tryValueError だけを見張っていると素通りします。

>>> 60 / (0 / 100) ** 2
Traceback (most recent call last):
  ...
ZeroDivisionError: float division by zero

対処: 割る前に止めます。完成コードは parse_number() の中で value <= 0 を弾いているので、この例外はそもそも発生しません。

if value <= 0:
    raise ValueError(f"{label}は0より大きい値を入力してください")

< 0 ではなく <= 0 にするのが要点です。< 0 だと0が通り抜けて、結局 ZeroDivisionError になります。割り算の分母になる値は「マイナスでないか」ではなく「0以下でないか」で確かめてください。

どうしても例外側で受け止めたい場合は except (ValueError, ZeroDivisionError) as e: と2つ並べて捕まえられますが、起きてから捕まえるより、起きないようにするほうが利用者に親切なメッセージを出せます。

❌ ValueError: could not convert string to float

原因: float() に渡した文字列が数値として読めません。BMIアプリで多いのは次の3つです。

  • 空欄のまま計算ボタンを押した → メッセージの末尾が : '' という空のクォートになります。ここが空なら「入力されていない」と即断できます。
  • 単位まで打ったcould not convert string to float: '170cm'。入力欄のラベルに (cm) と書いてあっても、律儀に単位を付ける人はいます。
  • 小数点をカンマで打ったcould not convert string to float: '170,5'

対処: 2段構えにします。①空欄は float() に渡す前に if not height_str or not weight_str: で止める、②それ以外の失敗は except ValueError で受けて messagebox で知らせる。完成コードの process() がこの形です。

height_str = self.entry_height.get().strip()
weight_str = self.entry_weight.get().strip()
if not height_str or not weight_str:
    messagebox.showwarning("警告", "身長と体重を入力してください")
    return

空欄のときだけ showerror ではなく showwarning を使い分けているのは、「打ち間違い」ではなく「まだ何も入れていない」状態だからです。他のエラーメッセージの逆引きはPythonエラー一覧&解決法にまとめています。

❌ 日本語入力のまま打つと弾かれる(全角の小数点)

原因: 全角の小数点です。Python 3.12.10 で確かめたところ、全角数字の "170" は 170.0 に変換できるのに、全角のピリオドを含む "170.5" は ValueError になりました。数字が通るぶん、原因が小数点だと気づきにくい厄介な失敗です。

対処: float() に渡す前に unicodedata.normalize("NFKC", ...) で正規化します。完成コードの parse_number() がこれをやっているので、"170.5" はそのまま 170.5 として通ります。

import unicodedata

print(float(unicodedata.normalize("NFKC", "170.5")))   # 170.5

ただしNFKCだけでは足りない文字があります。数学記号のマイナス(U+2212。全角ハイフン U+FF0D とは別の文字です)はNFKCを通しても変わらず、float() でエラーになりました。完成コードが .replace("−", "-") を足しているのはこのためです。身長や体重にマイナスを打つ場面はまれですが、同じ関数を他のアプリに持っていくときに効いてきます。

❌ 表示は25.00なのに判定が「普通体重」になる

原因: 浮動小数点数の丸め誤差です。この記事でいちばんお伝えしたい落とし穴です。身長ごとに「BMIがちょうど25.0になる体重」を入れて計算した実測結果を見てください。

身長体重計算されたBMI(生の値)画面の表示判定
150 cm56.25 kg25.025.00肥満(1度)
155 cm60.0625 kg24.99999999999999625.00普通体重
160 cm64.0 kg24.99999999999999625.00普通体重
165 cm68.0625 kg25.00000000000000425.00肥満(1度)
170 cm72.25 kg25.00000000000000425.00肥満(1度)
175 cm76.5625 kg25.025.00肥満(1度)
180 cm81.0 kg25.025.00肥満(1度)
185 cm85.5625 kg24.99999999999999625.00普通体重
190 cm90.25 kg25.025.00肥満(1度)

画面にはすべて「25.00」と出るのに、身長によって判定が2種類に割れます。これはPythonの不具合ではありません。原因は身長を2乗するところにあります。

print((150 / 100) ** 2)   # 2.25
print((160 / 100) ** 2)   # 2.5600000000000005  ← 2.56 にならない
print((170 / 100) ** 2)   # 2.8899999999999997  ← 2.89 にならない
print((175 / 100) ** 2)   # 3.0625
print((180 / 100) ** 2)   # 3.24

コンピュータは10進数の小数を2進数で近似して保持しているため、0.1 + 0.20.30000000000000004 になるのと同じ現象が起きます。1.51.75 のように2進数でぴったり表せる値では誤差が出ず、1.61.7 では出る、という違いです。

対処: 判定に使う値と、画面に出す値をそろえます。完成コードは表示と同じ小数第2位で丸めてから判定しています。

bmi = round(weight / height_m ** 2, 2)   # 判定も表示もこの値を使う

これで上の表の9件はすべて「肥満(1度)」に統一されました。ただし副作用があります。丸めた値で判定するので、本当のBMIが 24.995〜24.999 の人も「25.00 / 肥満(1度)」になります。実測すると 24.994 は「普通体重」、24.995 からは「肥満(1度)」でした。

どちらが正しいという話ではなく、「表示と判定の一致」と「境界の厳密さ」のどちらを取るかという選択です。この記事では、利用者が画面を見て矛盾を感じないことを優先しました。判定を厳密にしたい場合は round() を外し、表示の桁数を :.4f などに増やして、値そのものを見せてください。

💡
境界値をテストに入れておく

この問題は、2章check_bmi.py に「160cm 64kg → 25.00 → 肥満(1度)」を入れておいたから見つかりました。境界ちょうどの値をテストケースに混ぜておくと、丸め誤差はほぼ自動的に露見します。覚えておくと、他の分類処理でも役に立ちます。

❌ nan や inf を入れると変な判定が出る

原因: float()"nan""inf" を、例外を出さずにそのまま受け付けます。公式ドキュメントも「引数は NaN (not-a-number) や正または負の無限大をあらわす文字列でもかまいません」と明記しています。問題はそのあとです。

nan は大小比較(<><=>=)も == もすべて False を返します(!= だけが True)。そのため判定表の照合を全部すり抜けます。

bmi = float("nan")
print(bmi >= 40.0)   # False
print(bmi >= 18.5)   # False
print(bmi >= 0.0)    # False ← 最後の行にも引っかからない

そしてBMI特有の落とし穴がもうひとつあります。身長に inf を入れた場合です。

height_m = float("inf") / 100
bmi = 65 / height_m ** 2
print(bmi)                    # 0.0
print(math.isfinite(bmi))     # True ← 計算結果は有限値になってしまう

無限大で割ったのでBMIは 0.0 という完全に有限な値になり、「低体重(やせ)」と表示されます。計算結果の側でいくら math.isfinite() を掛けても検知できません。

対処: math.isfinite()計算後ではなく、入力を数値に変えた直後に置きます。完成コードの parse_number() がこの位置です。

value = float(normalized)
if not math.isfinite(value):
    raise ValueError(f"{label}には有限の数値を入力してください")

利用者がわざわざ nan と打つ場面は多くありませんが、「チェックを書いたのに通り抜ける値がある」「チェックを置く場所で結果が変わる」という事実自体を知っておく価値があります。CSVやAPIから数値を読む処理では、この落とし穴は現実に踏みます。

❌ 身長と体重を逆に入れても計算されてしまう

原因: 身長65cmも体重170kgも、単体で見れば「ありえない値」ではないからです。完成コードの範囲チェック(身長30〜300cm、体重5〜500kg)を両方とも通過し、BMI 402.37 → 肥満(4度)と表示されます(実測値)。

対処: 完全な解決策はありません。現実的な対応は次の3つです。

  1. 組み合わせで疑う: 8章の実験1のように「身長が低すぎるうえに体重のほうが大きい」場合だけ警告します。ただし正当な入力を弾かない加減が難しく、完全には防げません。
  2. 入力欄の順序と単位を明示する: ラベルに (cm) (kg) と書き、身長を先に置きます。完成コードはこの形です。地味ですが最も効きます。
  3. 入力値を結果に必ず表示する: 完成コードが結果欄に「身長 65.0 cm / 体重 170.0 kg」と出しているのは、利用者自身が取り違えに気づけるようにするためです。

3つ目が実はいちばん重要です。プログラム側で完璧に検出しようとするより、判断材料を利用者に見せるほうが確実な場面があると覚えておいてください。

❌ 判定や標準体重が画面に出ない

原因: 計算はできているのに、ウィンドウが小さくて結果欄に入りきっていません。1章の図1がまさにこの状態で、BMIの行までしか見えていません。

実測すると、ウィンドウが 500×400 のとき結果欄の Text ウィジェットの高さは 113ピクセル、1行が 21ピクセルなので5行しか表示できません。完成コードの結果は10行あります。

対処: ウィンドウを縦に広げます。完成コードは geometry("500x500") にしてあり、これで結果欄が213ピクセル=10行ぶんになります。

self.root.geometry("500x500")   # 結果は10行。400だと5行しか見えない

自分で行数を確かめたいときは、次のコードで測れます。

import tkinter.font as tkfont

font = tkfont.Font(font=self.result_text.cget("font"))
line_height = font.metrics("linespace")
print(self.result_text.winfo_height() // line_height, "行まで表示できます")

マウスでウィンドウを広げても見えるようになります。「計算が動いていない」と思ったら、まずウィンドウを広げてみてください。実際には計算できていて表示されていないだけ、というケースは珍しくありません。スクロールバーを付ける方法もありますが、pack() の順序に注意が必要です(温度変換アプリの実験5で実測つきで解説しています)。

❌ AttributeError: 'NoneType' object has no attribute 'get'

原因: ウィジェットの作成と配置を1行にまとめています。pack()grid()place() はいずれも None を返すので、変数には入力欄ではなく None が入ります。

# これはダメ(entry_height の中身は None になる)
self.entry_height = ttk.Entry(input_frame).grid(row=0, column=1)
self.entry_height.get()   # AttributeError: 'NoneType' object has no attribute 'get'

# 正しい書き方
self.entry_height = ttk.Entry(input_frame)
self.entry_height.grid(row=0, column=1)

対処: あとから get()config() を呼びたいウィジェットは、必ず作成と配置を2行に分けてください。逆に、二度と触らない tk.Label のようなウィジェットは1行にまとめても実害はありません。完成コードでラベルだけ .grid() をつなげて書いているのはそのためです。

入力欄が2つあるアプリでは、片方だけこの書き方になっていることがあります。「身長は取れるのに体重で落ちる」ときは、体重欄の行を見てください。AttributeError 全般の読み方はエラー解決ページのAttributeErrorの章にあります。

❌ Enterキーを押しても計算されない

原因: イベント名の取り違えが定番です。キーボードのEnterキーは <Return> で、<Enter> は「マウスカーソルがウィジェットに入った」という全く別のイベントです。

entry.bind("<Return>", lambda e: self.process())   # 正しい(Enterキー)
entry.bind("<Enter>", lambda e: self.process())    # マウスが乗ったときに動く

入力欄が2つあるアプリ特有の原因もあります。片方の欄にしか bind() していないケースです。体重欄だけに割り当ててあると、身長欄にカーソルがある状態でEnterを押しても何も起きません。完成コードは両方の欄に割り当てています。

対処: <Return> に直したうえで、lambda e: と引数を1つ受け取っているか確認してください。bind() で呼ばれる関数にはイベント情報のオブジェクトが必ず1つ渡されるため、引数なしの関数を渡すと呼び出しに失敗します。しかもそのエラーは画面ではなくターミナル側にだけ出ます。テンキーのEnterも拾いたい場合は <KP_Enter> を追加で割り当てます。

それでも反応しないときは、入力欄にフォーカスが当たっているかを疑ってください。完成コードは _build_ui() の最後で self.entry_height.focus_set() を呼んでおり、起動直後のフォーカスが身長欄になっていることを実測で確認しています。

出典: Python公式ドキュメント 組み込み関数 float()同 組み込み例外同 math.isfinite()(いずれも2026年8月19日確認)

10. 練習問題

手を動かして初めて身につきます。ヒントだけ添えるので、答えを見る前に自分で書いてみてください。

  1. 課題1: 判定基準をWHO基準に切り替えられるようにする

    2章の表の右列にあるWHO基準(Underweight / Normal range / Pre-obese / Obese class I〜III)でも判定できるようにしましょう。
    ヒント: BMI_CATEGORIES をもう1つ用意し、judge_bmi(bmi, categories=BMI_CATEGORIES) のように引数で受け取る形に変えるだけです。ifelif で書いていたら、この課題は大改造になっていました。

  2. 課題2: 標準体重までの差と、その達成に必要な体重を表示する

    標準体重との差を符号付きで出し、「あと◯kgで普通体重の範囲に入ります」という案内を添えましょう。
    ヒント: BMI 25 未満に収まる体重の上限は 25 * height_m ** 2 で求まります。差の表示は8章の実験3:+.1f を使ってください。※出力する案内文にも「計算上の目安です」の一言を添えておくと、アプリ単体で配ったときに誤解されません。

  3. 課題3: 入力チェックのテストを書く

    parse_number() に対して、通るべき文字列と弾かれるべき文字列の両方を assert で確かめるスクリプトを書きましょう。
    ヒント: 例外が出ることを確かめるには try / except ValueError / else: raise AssertionError(...) の形か、unittestassertRaises を使います。4章の表がそのままテストケースになります。

  4. 課題4: 計算履歴を残す

    計算するたびに結果を消さず、下に積み上げていくようにしましょう。
    ヒント: _show_result()delete() を消し、insert("1.0", ...)insert(tk.END, ...) に変えます。最新行が隠れないよう see(tk.END) も呼んでください。行数が増えるので、スクロールバーの追加も必要になります。

  5. 課題5: 身長をcm入力とm入力から選べるようにする

    Combobox で単位を選べるようにし、選ばれた単位に応じて変換しましょう。
    ヒント: 温度変換アプリComboboxStringVar の使い方を詳しく扱っています。単位を選ばせるなら、3章の「30未満ならm」という推測は不要になります。

11. よくある質問

Q. PythonでBMIを計算する式を教えてください。

bmi = weight / (height_cm / 100) ** 2 です。体重はkg、身長はcmで受け取って100で割りメートルに直します。身長170cm・体重65kgなら 22.49134948096886 になります。身長を100で割り忘れると答えが1万分の1になり、しかもエラーは出ませんので、2章の検算を必ず通してください。

Q. 身長をcmのまま計算したらBMIが0.002になりました。

100で割ってメートルに直す処理が抜けています。身長は2乗されるので、100倍のずれが1万倍のずれになります。print(f"{height_m=}") で確認し、170cmの人なら 1.7 になっていることを確かめてください。詳しくは症状別チェックにあります。

Q. ZeroDivisionError: float division by zero と出ます。

身長に 0 が入ったまま割り算に進んでいます。割る前に if value <= 0: raise ValueError(...) で止めてください< 0 だと0が通り抜けます)。float("0") が成功してしまう理由と、演算子ごとに変わるメッセージ4種の実測は該当項目にあります。

Q. 標準体重の 22 という数字はどこから来ているのですか?

厚生労働省の情報サイトが「男女とも標準とされるBMIは22.0ですが、これは統計上、肥満との関連が強い糖尿病、高血圧、脂質異常症(高脂血症)に最もかかりにくい数値とされています」と説明しており、標準体重を 身長(m)² × 22 と定義しています(出典・2026年8月19日確認)。

Q. BMIが25ちょうどのはずなのに「普通体重」と表示されます。

浮動小数点数の丸め誤差です。完成コードのように round(..., 2) で表示と同じ桁に丸めてから判定すれば、表示と判定がそろいます。同じ「25.00」表示でも身長によって判定が2種類に割れる9パターンの実測値と、丸めたときの副作用は症状別チェックにあります。

Q. 判定の境界(18.5や25.0)ちょうどはどちらに入りますか?

上の区分に入ります。18.5は「普通体重」、25.0は「肥満(1度)」です。分類表が 18.5 ≤ BMI < 25.0 という書き方になっているためで、完成コードの if bmi >= lower がこれと一致します。実際に境界の前後を通して確認した結果を6章の表に載せています。

Q. 入力欄が2つあるとき、チェックを2回書くしかないのでしょうか?

関数に切り出せば1回で済みます。完成コードの parse_number(text, label) がその形で、label を引数で受け取っているので「身長には…」「体重は…」とメッセージを出し分けられます。入力欄が3つ4つに増えても、呼ぶ行が増えるだけです。詳しくは6章を参照してください。

Q. 身長と体重を逆に入力されたときに検出できますか?

完全には検出できません。身長65cmも体重170kgも単体では範囲内なので、そのまま計算されてBMI 402.37と表示されます(実測値)。8章の実験1のような組み合わせ判定である程度は拾えますが、正当な入力を弾くリスクとの兼ね合いになります。入力値を結果に必ず表示して、利用者自身が気づけるようにするのが現実的です。

Q. 判定や標準体重が画面に表示されません。

計算はできていて、ウィンドウが小さくて見えていないだけの可能性が高いです。500×400のときは結果欄に5行しか入らず、完成コードの結果は10行あります。まずウィンドウを縦に広げてみてください。恒久対策は geometry("500x500") です。該当項目で実測値を示しています。

Q. int() ではなく float() を使うのはなぜですか?

身長は 170.5 cm、体重は 65.3 kg のように小数で入力されるからです。int("170.5")ValueError になり、小数点を含む入力がすべて弾かれてしまいます。整数だけを扱いたい場面でも、いったん float() で受けてから丸めるほうが利用者にとっては親切です。

Q. 全角で入力するとエラーになります。

全角数字だけなら通りますが、全角の小数点(.)が入ると弾かれますunicodedata.normalize("NFKC", text) で半角に寄せてから float() に渡してください。NFKCでも変換されないマイナス記号(U+2212)の追加対処は該当項目にあります。

Q. このアプリの判定結果を健康管理に使ってもいいですか?

おすすめしません。これはプログラミングの学習用サンプルで、公開されている分類表を機械的に当てはめているだけです。e-ヘルスネットに掲載されたこの分類表(日本肥満学会)にも「肥満(BMI≥25.0)は、医学的に減量を要する状態とは限らない」という注が付いています。BMIは身長と体重だけの指標なので、筋肉量の多い人などは実態と合いません。健康に関する判断は医療機関にご相談ください。

Q. GUIではなくコマンドラインだけで作りたいのですが。

2章input() を使ったコマンドライン版を掲載しています。計算部分はGUI版とほぼ同じで、違いは値の受け取り方と表示方法だけです。処理の中身と画面まわりを分けておくと、片方だけ差し替えられます。

Q. 体脂肪率も計算したいのですが。

BMIを材料にした推定式があります。体脂肪率計算アプリ(No.049)で、性別・年齢を加えた推定と Radiobutton の使い方を扱っています。判定によって表示を変えたい場合は8章の実験5(判定によって文字色を変える)で扱っています。

12. 次の一歩

このページで身についたのは「複数の入力を、同じ検査関数に通してから計算する」という型です。入力欄が3つでも10個でも、やることは変わりません。次はこの型を保ったまま、入力や表示を1つだけ複雑にするのがおすすめです。

次に作るものここで新しく身につく要素
体脂肪率計算アプリ(No.049)Radiobutton による選択と、性別で計算式が変わる分岐
温度変換アプリ(No.003)Combobox による単位の選択と、基準の単位にそろえる設計
tkinterでシンプル電卓を作るgrid() によるボタンの表配置と、入力の組み立て
カロリー計算(No.095)CSVファイルからデータを読み込む・合計を積み上げる

扱う対象を「1回の計算」から「複数件の一覧」へ広げたくなったら、次は Listbox の出番です。入力欄なら get() で文字列がそのまま返りますが、一覧では「いま選ばれている行の番号」を受け取ってから元データを引き直す手順が要ります。ToDoリストアプリで選択中の項目を正しく取り出す型を先に押さえておくと、この先どのアプリでも行番号のずれで詰まらずに済みます。

🚀
次に挑戦するアプリ

結果の見せ方を作り込みたいなら8章の実験5(判定によって文字色を変える)、単位の選択を学びたいなら温度変換が近道です。どちらもこのページで書いた try / except と検査関数の型がそのまま使えます。

tkinter のウィジェットをひととおり見渡したい場合はtkinterで最初のウィンドウを表示するの3層構造の説明が土台になります。GUI以外のライブラリも含めた全体像はPythonライブラリ一覧に、独学の順番を1冊で固めたい場合はPython学習本の比較にまとめてあります。なお tkinter は標準ライブラリなので、このページのアプリを動かすだけなら特別なPCは要りません。エディタと実行ウィンドウを開いたまま学習を続けたい、そろそろ買い替えを考えている、という段階になったらPython学習におすすめのパソコンと推奨スペックにメモリ・CPUの目安をまとめてあります。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。