model_fatsusus / app.py
Jjtumarai's picture
fix: pin setuptools<81 (detectron2 needs pkg_resources)
557ce2e
Raw
History Blame Contribute Delete
25.9 kB
# -*- coding: utf-8 -*-
# ============================================================
# app.py — หน้าเว็บ (Gradio) สำหรับใช้บนมือถือ
#
# ===== ต้องการอะไร =====
# ทุกชิ้นทำงานได้แล้ว แต่ยังต้องเรียกผ่าน Python เท่านั้น
# ผู้ใช้ทั่วไปเปิดใช้ไม่ได้ -> ต้องมีหน้าเว็บให้อัปโหลดรูปแล้วกดปุ่ม
#
# ===== ไฟล์นี้ทำอะไร =====
# สร้างหน้าเว็บ: อัปรูป + กรอกข้อมูล -> กดคำนวณ -> โชว์ผล
# เป็นไฟล์ที่ Hugging Face Space จะรัน (ต้องเปิดพอร์ต 7860)
#
# ===== วิธีคิด =====
# ทำงานหนักทั้งหมดอยู่ที่ features.py + pipeline.py แล้ว
# ไฟล์นี้แค่ "รับ input -> เรียก analyze() -> จัดข้อความสวยๆ" ไม่มี logic คำนวณเอง
# (ถ้ามีสูตรโผล่ในไฟล์นี้ = ผิดที่ ต้องย้ายไป nutrition.py)
#
# *** import features แบบ lazy ***
# เพราะ features ต้องใช้ detectron2 (Linux เท่านั้น)
# ถ้า import ตั้งแต่บนไฟล์ -> หน้าเว็บเปิดไม่ขึ้นเลยบน Windows
# ทำแบบ lazy = หน้าเว็บเปิดได้ทุกที่ แล้วค่อยฟ้อง error ตอนกดปุ่มถ้าไม่มี detectron2
# ============================================================
import os
import sys
import traceback
APP_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, APP_DIR) # ให้ import ไฟล์ข้างๆ ได้
# ============================================================
# ⚠️⚠️ บล็อกนี้ต้องอยู่บนสุด ห้ามมีอะไรมาก่อน ⚠️⚠️
#
# ===== ทำไมต้องมีบล็อกนี้ =====
# ZeroGPU เช็คตอนแอปเริ่มว่า "มีฟังก์ชันที่ครอบ @spaces.GPU ไหม"
# ถ้าไม่มี -> ฆ่าแอปทิ้ง ขึ้น "No @spaces.GPU function detected during startup"
# (เจอจริงตอน deploy 7 ส.ค. — Gradio เปิดได้แล้วแต่ถูกฆ่าใน 2 วินาที)
#
# ===== ทำไมต้องอยู่ "บนสุด" =====
# spaces ต้องถูก import ก่อนที่ "อะไรก็ตาม" จะไปแตะ CUDA
# เพราะมันต้องเข้าไปแทรกแซงระบบ GPU ตั้งแต่ยังไม่มีใครเปิดใช้
# ถ้าสายไป -> RuntimeError: CUDA has been initialized before importing `spaces`
#
# *** เคยพลาดมาแล้ว ***: เดิมวางบล็อกนี้ไว้ล่างไฟล์ แล้ว _bootstrap() ที่อยู่ข้างบน
# ดันเรียก torch.cuda.is_available() เพื่อ print สถานะลง log -> CUDA ตื่นก่อน -> พัง
# (บรรทัดที่ใส่ไว้ช่วยดีบัก กลายเป็นตัวที่ทำให้พังเอง)
#
# ===== ทำไมเป็นฟังก์ชันเปล่า =====
# งานจริงของเรารันบน CPU ทั้งหมด (detectron2 + Pose2Seg patch ให้ auto-detect แล้ว)
# ไม่ได้ต้องการ GPU จริง แค่ต้องมีฟังก์ชันนี้ให้ ZeroGPU ตรวจเจอเพื่อผ่านด่าน
# duration=10 = จองเวลา GPU สั้นสุด ไม่กินโควตา
# ============================================================
try:
import spaces
@spaces.GPU(duration=10)
def _zerogpu_probe():
"""ฟังก์ชันสำหรับให้ ZeroGPU ตรวจเจอตอน startup — ไม่ได้ใช้งานจริง"""
return 'ok'
print('[zerogpu] ✅ ลงทะเบียน @spaces.GPU probe แล้ว', flush=True)
except ImportError:
# รันนอก Space (เช่นเครื่อง Windows) ไม่มีโมดูล spaces -> ข้ามได้ ไม่ใช่ error
print('[zerogpu] ไม่มีโมดูล spaces (ไม่ได้อยู่บน ZeroGPU) — ข้าม', flush=True)
except RuntimeError as e:
# เผื่อมีอะไรปลุก CUDA ไปก่อนแล้ว — ไม่ให้แอปตายทั้งตัว
# (แอปยังเปิดได้ แต่ ZeroGPU อาจฆ่าทิ้งทีหลัง อย่างน้อยจะเห็นสาเหตุใน log)
print(f'[zerogpu] ⚠️ import spaces ไม่สำเร็จ: {e}', flush=True)
# ============================================================
# bootstrap — งานที่ Dockerfile เคยทำ ย้ายมาทำตอนแอปเริ่มรัน
#
# ทำไมต้องย้ายมาไว้ตรงนี้: Gradio SDK ไม่มี Dockerfile ให้สั่ง RUN
# มีแค่ packages.txt / requirements.txt ที่ลง lib ได้เท่านั้น
# งานอื่น (patch ไฟล์, ตั้ง path) จึงต้องทำตอน import แทน
#
# ⚠️ ต้องรันให้จบ "ก่อน" import features เสมอ
# เพราะ features จะ import Detected ที่ยังไม่ถูก patch -> พังทันที
# ============================================================
# ============================================================
# ติดตั้ง detectron2 ตอน runtime
#
# ===== ทำไมต้องลงตรงนี้ ไม่ลงใน requirements.txt =====
# setup.py ของ detectron2 ต้อง import torch เพื่อเช็คว่าเครื่องมี CUDA ไหม
# แต่ pip สร้าง "ห้องแยก" (build isolation) ตอน build wheel ซึ่งในห้องนั้นไม่มี torch
# -> ImportError -> build ล้ม (เจอจริงตอน build บน HF 7 ส.ค.)
# ทางแก้คือ --no-build-isolation แต่ flag นี้เขียนในไฟล์ requirements ไม่ได้
# -> ย้ายมาลงตอนรัน ซึ่งตอนนั้น torch อยู่ในสภาพแวดล้อมจริงแล้ว มองเห็นแน่นอน
#
# ===== ทำไมต้องลง "เบื้องหลัง" =====
# ลง detectron2 ต้อง compile ใช้เวลา 5-10 นาที
# ถ้ารอให้เสร็จก่อนค่อยเปิดเว็บ -> HF อาจตัดสินว่าแอปสตาร์ทไม่ขึ้นแล้วฆ่าทิ้ง
# ทำเบื้องหลัง -> หน้าเว็บขึ้นทันที + ผู้ใช้เห็นสถานะว่ากำลังติดตั้ง
# ============================================================
D2 = {'state': 'unknown', 'err': ''} # unknown / installing / ready / failed
D2_URL = 'git+https://github.com/facebookresearch/detectron2.git'
def _install_detectron2_bg():
"""ลง detectron2 ในเธรดเบื้องหลัง (ไม่บล็อกการเปิดหน้าเว็บ)"""
import threading
import subprocess
def work():
D2['state'] = 'installing'
print('[d2] เริ่มติดตั้ง detectron2 (ใช้เวลา 5-10 นาที) ...', flush=True)
cmd = [sys.executable, '-m', 'pip', 'install', '--no-cache-dir',
'--no-build-isolation', # ← หัวใจ: ห้ามสร้างห้องแยก จะได้เห็น torch
'torch==2.10.0', # ← ล็อกไว้ กัน pip อัป/ลง torch ทับตอนแอปกำลังใช้อยู่
'setuptools<81', # ← setuptools 81+ ลบ pkg_resources ที่ detectron2 ยังใช้อยู่
D2_URL]
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode == 0:
D2['state'] = 'ready'
print('[d2] ✅ ติดตั้ง detectron2 สำเร็จ', flush=True)
else:
D2['state'] = 'failed'
D2['err'] = (p.stderr or p.stdout or '')[-1500:] # เก็บท้าย log ไว้โชว์บนหน้าเว็บ
print('[d2] ❌ ติดตั้งล้มเหลว:', flush=True)
print(D2['err'], flush=True)
threading.Thread(target=work, daemon=True).start()
def _bootstrap():
on_space = os.path.isdir(os.path.join(APP_DIR, '2DImage2BMI-main'))
repo = os.path.join(APP_DIR, '2DImage2BMI-main')
# ---------- (0) detectron2 มีหรือยัง ถ้าไม่มีก็ลงเองเบื้องหลัง ----------
try:
import detectron2 # noqa: F401
D2['state'] = 'ready'
print('[d2] ✅ detectron2 มีอยู่แล้ว (ลงตอน build สำเร็จ)', flush=True)
except ImportError:
if on_space:
_install_detectron2_bg()
else:
D2['state'] = 'failed'
D2['err'] = 'ไม่มี detectron2 และไม่ได้อยู่บน Space (เครื่อง Windows รันส่วนนี้ไม่ได้)'
# ---------- (1) ตั้งที่อยู่ไฟล์ให้ features.py หาเจอ ----------
# ใช้ setdefault -> ถ้ามีคนตั้ง env มาก่อนแล้วจะไม่ทับของเขา
defaults = {
'BMI_REPO': repo,
'POSE2SEG_PKL': os.path.join(APP_DIR, 'models', 'pose2seg_release.pkl'),
'DENSE_PT': os.path.join(APP_DIR, 'results', 'dense', 'dense_bmi_best.pt'),
'BUNDLE_PATH': os.path.join(APP_DIR, 'results', 'dense', 'bmi_bundle.pkl'),
}
if on_space:
for k, v in defaults.items():
os.environ.setdefault(k, v)
# ---------- (2) patch repo (ตัด import ที่ไม่มี + เปลี่ยน .cuda() เป็น auto-detect) ----------
# patch_detected รันซ้ำได้ ไม่พัง -> เรียกทุกครั้งที่แอปตื่นได้เลย ไม่ต้องเช็คก่อน
if on_space:
try:
import patch_detected
print('[bootstrap] patching repo ...', flush=True)
rc = patch_detected.main(repo)
print(f'[bootstrap] patch เสร็จ (rc={rc})', flush=True)
except Exception as e:
# ไม่ raise ต่อ เพราะอยากให้หน้าเว็บเปิดขึ้นมาแล้วโชว์ error ให้อ่านได้
# ดีกว่า Space ตายเงียบๆ แล้วต้องไปไล่ดู log เอง
print(f'[bootstrap] ⚠️ patch ล้มเหลว: {type(e).__name__}: {e}', flush=True)
# ---------- (3) รายงานสภาพแวดล้อมลง log (ไว้ไล่ปัญหาตอน build) ----------
print('[bootstrap] --- สภาพแวดล้อม ---', flush=True)
for mod in ('numpy', 'torch', 'cv2', 'sklearn', 'detectron2'):
try:
m = __import__(mod)
print(f'[bootstrap] ✅ {mod:12s} {getattr(m, "__version__", "?")}', flush=True)
except Exception as e:
print(f'[bootstrap] ❌ {mod:12s} {type(e).__name__}: {e}', flush=True)
try:
import torch
print(f'[bootstrap] device = {"cuda" if torch.cuda.is_available() else "cpu"}', flush=True)
except Exception:
pass
_bootstrap()
import gradio as gr
import nutrition as N
from pipeline import analyze
# ---------- ตัวเลือกในหน้าเว็บ (ภาษาไทย) -> ค่าที่โค้ดใช้จริง ----------
SEX_CHOICES = {'ชาย': N.SEX_MALE, 'หญิง': N.SEX_FEMALE}
ACTIVITY_CHOICES = {
'นั่งโต๊ะ ไม่ออกกำลังกาย': 'sedentary',
'ออกกำลังกายเบา 1-3 วัน/สัปดาห์': 'light',
'ปานกลาง 3-5 วัน/สัปดาห์': 'moderate',
'หนัก 6-7 วัน/สัปดาห์': 'heavy',
'หนักมาก ทุกวันเช้าเย็น': 'very_heavy',
}
GOAL_CHOICES = {
'ลดไขมัน': 'lose',
'คงน้ำหนัก': 'maintain',
'เพิ่มกล้าม': 'gain',
}
# ============================================================
# ฟังก์ชันที่ถูกเรียกตอนกดปุ่ม "คำนวณ"
# ============================================================
def run(image_path, height_cm, age, sex_th, activity_th, goal_th, target_bf):
"""
รับค่าจากหน้าเว็บ -> คืนข้อความผลลัพธ์ (markdown)
ทุก error ต้องถูกจับแล้วแปลงเป็นข้อความที่ผู้ใช้อ่านรู้เรื่อง
ห้ามปล่อยให้ traceback ดิบโผล่บนหน้าเว็บ (ผู้ใช้ไม่เข้าใจ + ดูไม่โปร)
"""
# ---------- เช็คว่าอัปรูปมาหรือยัง ----------
if not image_path:
return '### ⚠️ ยังไม่ได้เลือกรูป\nกรุณาอัปโหลดรูปถ่ายเต็มตัวก่อนกดคำนวณ'
# ---------- เช็คว่าโมดูลวิเคราะห์รูปพร้อมหรือยัง ----------
# detectron2 ถูกลงเบื้องหลังตอนแอปเริ่ม -> ช่วงแรกอาจยังไม่เสร็จ
# ต้องบอกผู้ใช้ให้ชัด ดีกว่าปล่อยให้กดแล้วเจอ error งงๆ
if D2['state'] == 'installing':
return ('### ⏳ ระบบกำลังเตรียมตัว\n'
'กำลังติดตั้งโมดูลวิเคราะห์ภาพ (ครั้งแรกเท่านั้น ใช้เวลา 5-10 นาที)\n\n'
'กรุณารอสักครู่แล้วกดวิเคราะห์อีกครั้ง')
if D2['state'] == 'failed':
return ('### ❌ ติดตั้งโมดูลวิเคราะห์ภาพไม่สำเร็จ\n'
'ส่วนวิเคราะห์รูปใช้งานไม่ได้ในขณะนี้\n\n'
f'```\n{D2["err"][-800:]}\n```')
# ---------- แปลงตัวเลือกภาษาไทย -> ค่าที่โค้ดใช้ ----------
sex = SEX_CHOICES[sex_th]
activity = ACTIVITY_CHOICES[activity_th]
goal = GOAL_CHOICES[goal_th]
# ---------- ขั้นที่ 1: รูป -> 22 features (ขั้นที่ต้องใช้ detectron2) ----------
try:
from features import image_to_features # lazy import ตรงนี้
feat = image_to_features(image_path)
except ImportError as e:
return (f'### ❌ ระบบยังไม่พร้อม\n'
f'โมดูลวิเคราะห์รูปยังโหลดไม่ได้ (ต้องรันบน Linux/Docker)\n\n```\n{e}\n```')
except Exception as e:
# เคสที่ผู้ใช้เจอบ่อยสุด: รูปไม่มีคน / เห็นไม่เต็มตัว
return (f'### ❌ วิเคราะห์รูปไม่สำเร็จ\n\n{e}\n\n'
f'**วิธีถ่ายให้ได้ผลดี**\n'
f'- ถ่ายเต็มตัว เห็นตั้งแต่หัวถึงเท้า\n'
f'- ยืนตรง แขนแนบลำตัว\n'
f'- คนเดียวในรูป พื้นหลังโล่ง\n'
f'- แสงสว่างพอ ไม่ย้อนแสง')
# ---------- ขั้นที่ 2: 22 features + ข้อมูลผู้ใช้ -> ผลลัพธ์ทั้งหมด ----------
try:
r = analyze(feat['ratio7'], feat['deep15'],
height_cm=height_cm, age=age, sex=sex,
activity=activity, goal=goal, target_bf=target_bf)
except Exception:
return f'### ❌ คำนวณไม่สำเร็จ\n```\n{traceback.format_exc()}\n```'
# ---------- ถ้า input ไม่ผ่านการตรวจ ----------
if not r['ok']:
lines = '\n'.join(f'- {e}' for e in r['errors'])
return f'### ⚠️ ข้อมูลไม่ถูกต้อง\n{lines}\n\nกรุณาแก้แล้วลองใหม่'
return _format(r)
# ============================================================
# จัดผลลัพธ์ให้อ่านง่ายบนมือถือ
# ============================================================
def _format(r):
m, c, meals = r['macros'], r['bmr_compare'], r['meals']
txt = f"""
## 📊 ผลการประเมิน
| | |
|---|---|
| **BMI** | **{r['bmi']:.1f}** ({r['bmi_label']}) |
| **% ไขมัน** | **{r['body_fat_pct']:.1f}%** ({r['body_fat_label']}) |
| น้ำหนักโดยประมาณ | {r['weight_kg']:.1f} kg |
| มวลไขมัน | {r['fat_mass_kg']:.1f} kg |
| มวลไร้ไขมัน (LBM) | {r['lbm_kg']:.1f} kg |
> ⚠️ %ไขมันเป็น **ค่าประมาณ ± 5%** จากภาพถ่าย ไม่ใช่การวัดทางการแพทย์
---
## 🔥 พลังงานที่ต้องการ
| | |
|---|---|
| BMR (Katch-McArdle) | {c['katch']:.0f} kcal |
| BMR (Mifflin-St Jeor) | {c['mifflin']:.0f} kcal |
| *ส่วนต่างของ 2 สูตร* | *{c['diff']:+.0f} kcal ({c['diff_pct']:+.1f}%)* |
| TDEE (เผาผลาญจริง/วัน) | {r['tdee']:.0f} kcal |
| **ควรกินวันละ** | **{r['kcal_target']:.0f} kcal** |
> ใช้ **Katch-McArdle** เป็นหลักเพราะคำนวณจากมวลไร้ไขมัน (LBM) ซึ่งได้จาก %ไขมันที่วิเคราะห์จากรูป
> จึงแม่นกว่าสูตรที่ใช้แค่น้ำหนักตัว
---
## 🍽️ สารอาหารต่อวัน
| สารอาหาร | ปริมาณ | พลังงาน |
|---|---|---|
| โปรตีน | **{m['protein_g']:.0f} g** | {m['protein_g'] * 4:.0f} kcal |
| ไขมัน | **{m['fat_g']:.0f} g** | {m['fat_g'] * 9:.0f} kcal |
| คาร์โบไฮเดรต | **{m['carb_g']:.0f} g** | {m['carb_g'] * 4:.0f} kcal |
### แบ่งเป็น 3 มื้อ
| มื้อ | พลังงาน | โปรตีน | ไขมัน | คาร์บ |
|---|---|---|---|---|
"""
for name, d in meals.items():
txt += (f"| {name} | {d['kcal']:.0f} kcal | {d['protein_g']:.0f} g "
f"| {d['fat_g']:.0f} g | {d['carb_g']:.0f} g |\n")
if r['target_weight_kg']:
diff = r['target_weight_kg'] - r['weight_kg']
direction = 'ลด' if diff < 0 else 'เพิ่ม'
txt += (f"\n---\n\n## 🎯 เป้าหมาย\n"
f"ถ้าต้องการ %ไขมันตามที่ตั้งไว้ → น้ำหนักเป้าหมาย **{r['target_weight_kg']:.1f} kg** "
f"({direction} {abs(diff):.1f} kg จากตอนนี้)\n")
if m.get('warning'):
txt += f"\n> ⚠️ {m['warning']}\n"
return txt
# ============================================================
# สร้างหน้าเว็บ
# ============================================================
def build_ui():
# Gradio 6 ย้าย theme จาก Blocks() ไป launch() -> ตรงนี้ไม่ใส่ theme แล้ว
with gr.Blocks(title='ประเมิน %ไขมันจากภาพถ่าย') as demo:
gr.Markdown(
'# 📷 ประเมิน % ไขมัน และแคลอรี่ที่ควรได้รับต่อวัน\n'
'อัปโหลดรูปถ่ายเต็มตัว + กรอกข้อมูลพื้นฐาน → ระบบจะประเมิน %ไขมัน '
'แล้วคำนวณพลังงานและสารอาหารที่เหมาะสม'
)
with gr.Row():
# ---------- ฝั่งซ้าย: input ----------
with gr.Column():
img = gr.Image(type='filepath', label='📷 รูปถ่ายเต็มตัว (ยืนตรง คนเดียว พื้นหลังโล่ง)')
height = gr.Number(value=170, label='ส่วนสูง (ซม.)', minimum=100, maximum=220)
age = gr.Number(value=25, label='อายุ (ปี)', minimum=15, maximum=80)
sex = gr.Radio(list(SEX_CHOICES), value='ชาย', label='เพศ')
act = gr.Dropdown(list(ACTIVITY_CHOICES), value='ปานกลาง 3-5 วัน/สัปดาห์',
label='ระดับกิจกรรม')
goal = gr.Radio(list(GOAL_CHOICES), value='คงน้ำหนัก', label='เป้าหมาย')
tbf = gr.Slider(5, 40, value=15, step=1, label='% ไขมันที่อยากได้ (สำหรับคำนวณน้ำหนักเป้าหมาย)')
btn = gr.Button('🔍 วิเคราะห์', variant='primary', size='lg')
# ---------- ฝั่งขวา: output ----------
with gr.Column():
out = gr.Markdown('ผลลัพธ์จะแสดงตรงนี้หลังกดปุ่มวิเคราะห์\n\n'
'*(ครั้งแรกอาจใช้เวลา 20-30 วินาที เพราะต้องโหลดโมเดล)*')
btn.click(run, inputs=[img, height, age, sex, act, goal, tbf], outputs=out)
gr.Markdown(
'---\n'
'**ข้อจำกัด:** ค่าที่ได้เป็นการประมาณจากภาพถ่าย 2 มิติ (± 5%) '
'ไม่สามารถใช้แทนการวัดด้วยเครื่อง DEXA หรือ InBody ได้\n\n'
'*โครงงาน: การพัฒนาแอปพลิเคชันประเมินเปอร์เซ็นต์ไขมันในร่างกายจากภาพถ่ายด้วยเทคนิค Computer Vision*'
)
return demo
if __name__ == '__main__':
demo = build_ui()
# HF Spaces บังคับใช้พอร์ต 7860 และต้อง bind 0.0.0.0 (ไม่ใช่ localhost) ไม่งั้นเข้าจากข้างนอกไม่ได้
opts = dict(server_name='0.0.0.0', server_port=int(os.environ.get('PORT', 7860)))
# Gradio 6 รับ theme ที่ launch() — แต่เวอร์ชันเก่ารับที่ Blocks()
# ลองใส่ก่อน ถ้าไม่รองรับก็ปล่อยไปโดยไม่มี theme (สวยน้อยลงแต่ "แอปต้องขึ้นให้ได้" สำคัญกว่า)
try:
demo.launch(theme=gr.themes.Soft(), **opts)
except TypeError:
print('[ui] เวอร์ชัน Gradio ไม่รับ theme ที่ launch() — เปิดแบบไม่มี theme', flush=True)
demo.launch(**opts)