# -*- 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)