Spaces:
Paused
Paused
| # -*- 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 | |
| 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) | |