Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| import os as _o | |
| # chdir กลับ project root — เฉพาะตอนที่อยู่ในโครงโปรเจกต์จริง (บน Docker ไฟล์ถูกวางแบนๆ ต้องไม่ chdir) | |
| _root = _o.path.dirname(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__)))) | |
| if _o.path.isdir(_o.path.join(_root, 'results')): | |
| _o.chdir(_root) | |
| # ============================================================ | |
| # features.py — รูป 1 ใบ -> 22 ตัวเลข (7 ratio + 15 deep) | |
| # | |
| # ===== ต้องการอะไร ===== | |
| # นี่คือ "ท่อต้นน้ำ" ที่ยังขาดอยู่ชิ้นเดียว | |
| # predict_bmi.py กับ pipeline.py ทำงานได้แล้ว แต่ต้องป้อน 22 ตัวเลขให้มันเอง | |
| # ไฟล์นี้ทำให้ป้อน "รูป" ได้เลย = แอปรับรูปจากผู้ใช้ได้จริง | |
| # | |
| # ===== ไฟล์นี้ทำอะไร ===== | |
| # รับ path รูป -> คืน (ratio7, deep15) พร้อมส่งต่อให้ predict_bmi | |
| # ทำ 2 สายพร้อมกัน: | |
| # สาย A: detectron2 (หาคน+ข้อต่อ) -> Pose2Seg (แยกร่างจากพื้นหลัง) -> วัดสัดส่วน -> 7 ratio | |
| # สาย B: DenseNet121 ที่ fine-tune แล้ว -> 15 deep features | |
| # | |
| # *** ไฟล์นี้รันบน Windows ไม่ได้ *** | |
| # detectron2 ต้อง Linux -> ใช้งานจริงบน Hugging Face Spaces (Docker) เท่านั้น | |
| # ที่เหลือ (nutrition/predict_bmi/pipeline) รันบน Windows ได้หมด จึงแยกไฟล์นี้ออกมา | |
| # | |
| # ===== วิธีคิด (สำคัญที่สุดในไฟล์นี้) ===== | |
| # ทุกขั้นตอนต้องทำ "เหมือนตอนเทรนบน Kaggle เป๊ะ" ไม่งั้นตัวเลขที่ได้จะเป็นคนละชุด | |
| # กับที่ SVR เคยเห็น -> ทำนาย BMI มั่วโดยไม่มี error ฟ้อง (พังเงียบ) | |
| # จุดที่ต้องเหมือนเป๊ะ: | |
| # 1. เตรียมรูป: ย่อด้านยาว=224 + เติมขอบ "ดำ" + normalize ค่า ImageNet | |
| # 2. โมเดลหาข้อต่อ: keypoint_rcnn_R_101_FPN_3x (R_101 ไม่ใช่ R_50) | |
| # 3. Detected.py ต้องเป็นตัวที่ patch แล้ว (ตัด SCHP/PSP/CPM/CRFRNN) | |
| # -> mask จะ "มีแขนติดอยู่" ซึ่งตอนเทรนก็มีแขน = ต้องปล่อยให้มีเหมือนกัน | |
| # ============================================================ | |
| import os | |
| import numpy as np | |
| # ---------- ที่อยู่ไฟล์ (ตั้งผ่าน environment variable ได้ เพื่อให้ Docker เปลี่ยนง่าย) ---------- | |
| MODEL_DIR = os.environ.get('MODEL_DIR', 'results/dense') | |
| DENSE_PT = os.environ.get('DENSE_PT', os.path.join(MODEL_DIR, 'dense_bmi_best.pt')) | |
| POSE2SEG_PKL = os.environ.get('POSE2SEG_PKL', 'models/pose2seg_release.pkl') | |
| BMI_REPO = os.environ.get('BMI_REPO', '2DImage2BMI-main') # โฟลเดอร์ที่มี Detected.py (patch แล้ว) | |
| # ===== แปลงทุก path เป็น absolute ทันทีตอน import ===== | |
| # ทำไมต้องทำ: _load_anthro() ต้อง os.chdir() เข้าไปใน repo (เพราะ Detected.py อ้าง path แบบ relative ข้างใน) | |
| # ถ้าปล่อย path เป็น relative ไว้ พอ chdir แล้ว path เดิมจะชี้ผิดที่ทันที -> หาไฟล์ไม่เจอแบบงงๆ | |
| DENSE_PT = os.path.abspath(DENSE_PT) | |
| POSE2SEG_PKL = os.path.abspath(POSE2SEG_PKL) | |
| BMI_REPO = os.path.abspath(BMI_REPO) | |
| # ---------- ชื่อ+ลำดับคอลัมน์ (ต้องตรงกับ bmi_bundle.pkl เป๊ะ ห้ามสลับ) ---------- | |
| RATIO_COLS = ['WSR', 'WTR', 'WHpR', 'WHdR', 'HpHdR', 'Area', 'H2W'] | |
| N_DEEP = 15 | |
| # ---------- ค่า normalize ของ ImageNet (ตัวเดียวกับตอนเทรน) ---------- | |
| IMAGENET_MEAN = [0.485, 0.456, 0.406] | |
| IMAGENET_STD = [0.229, 0.224, 0.225] | |
| IMG_SIZE = 224 | |
| _net = None # DenseNet — โหลดครั้งเดียวแล้วใช้ซ้ำ | |
| _proc = None # detectron2 + Pose2Seg — ตัวนี้หนักมาก ยิ่งต้องโหลดครั้งเดียว | |
| # ============================================================ | |
| # สาย B (1/2): เตรียมรูปให้เหมือนตอนเทรน | |
| # ============================================================ | |
| def _resize_pad(pil_img, size=IMG_SIZE): | |
| """ | |
| ย่อรูปให้ "ด้านที่ยาวที่สุด" = 224 แล้วเติมขอบดำจนเป็นสี่เหลี่ยม 224x224 | |
| ทำไมไม่ resize ตรงๆ เป็น 224x224: | |
| รูปคนส่วนใหญ่เป็นแนวตั้ง (สูงมากกว่ากว้าง) ถ้าบีบเป็นจัตุรัสตรงๆ | |
| คนผอมจะกลายเป็นคนอ้วนในรูป -> CNN อ่านสัดส่วนผิดหมด | |
| การเติมขอบดำรักษาสัดส่วนตัวจริงไว้ (เหมือนดูหนังจอกว้างมีแถบดำบนล่าง) | |
| """ | |
| from PIL import Image | |
| w, h = pil_img.size | |
| k = size / max(w, h) # อัตราย่อ ยึดด้านที่ยาวสุด | |
| img = pil_img.resize((max(1, int(w * k)), max(1, int(h * k)))) | |
| canvas = Image.new('RGB', (size, size), (0, 0, 0)) # ผ้าใบดำ 224x224 | |
| canvas.paste(img, ((size - img.width) // 2, (size - img.height) // 2)) # วางไว้ตรงกลาง | |
| return canvas | |
| def _get_transform(): | |
| """สร้างชุดแปลงรูป: resize+pad -> tensor -> normalize (เหมือนตอนเทรนทุกขั้น)""" | |
| from torchvision import transforms | |
| return transforms.Compose([ | |
| transforms.Lambda(_resize_pad), | |
| transforms.ToTensor(), # 0-255 -> 0.0-1.0 | |
| transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), # ปรับให้ตรงกับที่ DenseNet เคยเรียนมา | |
| ]) | |
| # ============================================================ | |
| # สาย B (2/2): โครงโมเดล DenseNet — ต้องเหมือนตอนเทรน ไม่งั้นโหลด .pt ไม่ได้ | |
| # ============================================================ | |
| def _build_densenet(): | |
| """ | |
| สร้างโครงเปล่าให้เหมือนตอนเทรน แล้วค่อยเทค่าน้ำหนักจาก .pt ใส่ | |
| โครง: DenseNet121 -> เปลี่ยนชั้นท้ายให้ออก 15 ตัว -> head(15->1) ทำนาย BMI | |
| ตอนใช้งานจริงเราเอาแค่ "15 ตัวกลาง" ทิ้ง head | |
| (head มีไว้ตอนเทรนเพื่อบังคับให้ 15 ตัวนั้นมีความหมายเกี่ยวกับ BMI) | |
| """ | |
| import torch.nn as nn | |
| from torchvision import models | |
| class DenseBMI(nn.Module): | |
| def __init__(self): | |
| super().__init__() | |
| self.backbone = models.densenet121(weights=None) # weights=None เพราะเดี๋ยวโหลดจาก .pt ทับ | |
| self.backbone.classifier = nn.Linear(self.backbone.classifier.in_features, N_DEEP) | |
| self.head = nn.Linear(N_DEEP, 1) | |
| def forward(self, x): | |
| f = self.backbone(x) # -> 15 deep features | |
| return self.head(f), f # คืน (BMI ที่ทำนาย, 15 features) | |
| return DenseBMI() | |
| def _load_densenet(): | |
| """โหลด DenseNet ครั้งแรกครั้งเดียว (lazy load) — ครั้งต่อไปใช้ตัวเดิม""" | |
| global _net | |
| if _net is not None: | |
| return _net | |
| import torch | |
| if not os.path.exists(DENSE_PT): | |
| raise FileNotFoundError(f'ไม่เจอไฟล์โมเดล {DENSE_PT} — ตั้ง env DENSE_PT ให้ถูก หรือก็อปไฟล์มาวาง') | |
| net = _build_densenet() | |
| state = torch.load(DENSE_PT, map_location='cpu') # map_location='cpu' = ใช้ได้แม้ไม่มี GPU | |
| net.load_state_dict(state) | |
| net.eval() # โหมดใช้งาน (ปิด dropout/batchnorm update) | |
| _net = net | |
| return _net | |
| # ============================================================ | |
| # สาย A: detectron2 + Pose2Seg -> 7 ratio | |
| # ============================================================ | |
| def _load_anthro(): | |
| """ | |
| โหลดตัวสกัดสัดส่วน (หนักสุดในระบบ — โหลดครั้งเดียวพอ) | |
| ⚠️ ตัวนี้ต้อง Linux เท่านั้น (detectron2 build บน Windows ไม่ผ่าน) | |
| """ | |
| global _proc | |
| if _proc is not None: | |
| return _proc | |
| import sys | |
| # ต้อง chdir เข้า repo ก่อน เพราะ Detected.py อ้าง path แบบ relative ข้างใน | |
| if not os.path.isdir(BMI_REPO): | |
| raise FileNotFoundError(f'ไม่เจอโฟลเดอร์ {BMI_REPO} (ที่มี Detected.py) — ตั้ง env BMI_REPO ให้ถูก') | |
| sys.path.insert(0, os.path.abspath(BMI_REPO)) | |
| os.chdir(BMI_REPO) | |
| try: | |
| from Detected import Image_Processor | |
| except ImportError as e: | |
| raise ImportError( | |
| f'import Detected ไม่ผ่าน: {e}\n' | |
| ' เช็ค 3 อย่าง:\n' | |
| ' 1) Detected.py เป็นตัวที่ patch แล้วหรือยัง (ต้องตัด import HumanParser/PSP/CPM/CRFRNN)\n' | |
| ' 2) เอา lib/ ของ Pose2Seg มาเติมใน repo แล้วหรือยัง (lib.transforms, lib.timer)\n' | |
| ' 3) numpy เป็น 1.26.4 หรือยัง (numpy 2.0 ลบ np.mat -> Pose2Seg พัง)' | |
| ) from e | |
| if not os.path.exists(POSE2SEG_PKL): | |
| raise FileNotFoundError(f'ไม่เจอ {POSE2SEG_PKL} (โมเดล Pose2Seg)') | |
| # ===== หัวใจ: R_101 ไม่ใช่ R_50 — ต้องตรงกับตอนเทรน ไม่งั้นจุดข้อต่อเลื่อน ratio เพี้ยน ===== | |
| _proc = Image_Processor(POSE2SEG_PKL, 'COCO-Keypoints/keypoint_rcnn_R_101_FPN_3x.yaml') | |
| return _proc | |
| def extract_ratio(bgr_image): | |
| """ | |
| รับ : รูปแบบ BGR (numpy array จาก cv2.imread) | |
| คืน : list 7 ค่า เรียงตาม RATIO_COLS | |
| ข้างใน Process() จะ: หาคน -> หาข้อต่อ 17 จุด -> วาด mask -> วัดความกว้างที่ระดับต่างๆ | |
| แล้วคำนวณเป็นอัตราส่วน (อัตราส่วนไม่ขึ้นกับระยะถ่ายใกล้/ไกล จึงใช้แทนหน่วย cm ได้) | |
| """ | |
| P = _load_anthro() | |
| try: | |
| F = P.Process(bgr_image) | |
| except Exception as e: | |
| # เคสที่เจอบ่อยสุด: รูปไม่มีคน / คนถูกบัง / มีหลายคนจนสับสน | |
| raise RuntimeError( | |
| f'สกัดสัดส่วนจากรูปไม่สำเร็จ ({type(e).__name__}: {e})\n' | |
| ' มักเกิดจาก: ในรูปไม่มีคน / เห็นไม่เต็มตัว / มีหลายคน / รูปมืดเกินไป\n' | |
| ' แนะนำผู้ใช้: ถ่ายเต็มตัว ยืนตรง พื้นหลังโล่ง คนเดียวในรูป' | |
| ) from e | |
| return [float(getattr(F, c)) for c in RATIO_COLS] # ดึงตามลำดับ RATIO_COLS เป๊ะ | |
| def extract_deep(pil_image): | |
| """ | |
| รับ : รูป PIL (RGB) | |
| คืน : list 15 ค่า (deep features จาก DenseNet ที่ fine-tune แล้ว) | |
| """ | |
| import torch | |
| net = _load_densenet() | |
| tf = _get_transform() | |
| x = tf(pil_image).unsqueeze(0) # เพิ่มมิติ batch -> (1, 3, 224, 224) | |
| with torch.no_grad(): # ไม่ต้องคิดอนุพันธ์ = เร็วขึ้น ประหยัดแรม | |
| _, feat = net(x) # เอาแค่ feat ทิ้งค่าทำนาย BMI ของ head | |
| return feat.cpu().numpy()[0].astype(float).tolist() | |
| # ============================================================ | |
| # หัวใจ: รูป -> 22 ตัวเลข (ฟังก์ชันเดียวที่ข้างนอกเรียก) | |
| # ============================================================ | |
| def image_to_features(image_path): | |
| """ | |
| รับ : path รูป | |
| คืน : dict {'ratio7': [...7], 'deep15': [...15]} | |
| เอาไปต่อกับ pipeline.analyze(ratio7, deep15, ...) ได้ทันที | |
| """ | |
| import cv2 | |
| from PIL import Image | |
| if not os.path.exists(image_path): | |
| raise FileNotFoundError(f'ไม่เจอรูป: {image_path}') | |
| # ---------- สาย A: ต้องใช้ BGR (cv2) ---------- | |
| bgr = cv2.imread(image_path) | |
| if bgr is None: | |
| raise ValueError(f'อ่านรูปไม่ได้ (ไฟล์เสียหรือไม่ใช่รูป?): {image_path}') | |
| ratio7 = extract_ratio(bgr) | |
| # ---------- สาย B: ต้องใช้ RGB (PIL) ---------- | |
| # ⚠️ ระวัง: cv2 อ่านมาเป็น BGR ส่วน PIL เป็น RGB — สลับกันเมื่อไหร่สีเพี้ยน CNN อ่านผิด | |
| pil = Image.open(image_path).convert('RGB') | |
| deep15 = extract_deep(pil) | |
| # ---------- ตรวจว่าได้ครบจำนวนจริง ---------- | |
| if len(ratio7) != len(RATIO_COLS): | |
| raise RuntimeError(f'ratio ควรได้ {len(RATIO_COLS)} ค่า แต่ได้ {len(ratio7)}') | |
| if len(deep15) != N_DEEP: | |
| raise RuntimeError(f'deep ควรได้ {N_DEEP} ค่า แต่ได้ {len(deep15)}') | |
| # ---------- ดักค่าเสีย (inf/nan) ที่จะทำให้ SVR พังหรือทายมั่ว ---------- | |
| allv = np.array(ratio7 + deep15, dtype=float) | |
| if not np.isfinite(allv).all(): | |
| bad = [RATIO_COLS[i] for i in range(len(ratio7)) if not np.isfinite(ratio7[i])] | |
| raise RuntimeError(f'ได้ค่าเสีย (inf/nan) จากรูปนี้ ตัวที่เสีย: {bad or "deep features"}\n' | |
| ' มักเกิดตอนวัดความกว้างได้ 0 แล้วเอาไปหาร -> ถ่ายใหม่ให้เห็นเต็มตัว') | |
| return {'ratio7': ratio7, 'deep15': deep15} | |
| # ============================================================ | |
| # เทสตัวเอง | |
| # บน Windows: จะเช็คได้แค่ "สาย B (DenseNet)" เท่านั้น | |
| # สาย A ต้องรอ Linux — จะขึ้นข้อความบอกชัดว่าเช็คไม่ได้ ไม่ใช่พัง | |
| # | |
| # วิธีใช้: python scripts/app/features.py <path รูป> | |
| # ============================================================ | |
| if __name__ == '__main__': | |
| import sys | |
| print('=' * 66) | |
| print(' เทส features.py') | |
| print('=' * 66) | |
| # ---------- เช็คว่าของที่ต้องใช้มีครบไหม ---------- | |
| print('\n [1] เช็คไฟล์ที่ต้องใช้:') | |
| for label, p in [('DenseNet .pt', DENSE_PT), ('Pose2Seg .pkl', POSE2SEG_PKL), ('repo Detected.py', BMI_REPO)]: | |
| print(f' {"✅" if os.path.exists(p) else "❌"} {label:18s} {p}') | |
| # ---------- เช็คว่า library ลงครบไหม ---------- | |
| print('\n [2] เช็ค library:') | |
| for mod, note in [('torch', 'สาย B'), ('torchvision', 'สาย B'), ('cv2', 'อ่านรูป'), | |
| ('detectron2', 'สาย A — Linux เท่านั้น')]: | |
| try: | |
| __import__(mod) | |
| print(f' ✅ {mod:14s} ({note})') | |
| except ImportError: | |
| print(f' ❌ {mod:14s} ({note}) — ยังไม่ได้ลง') | |
| # ---------- ถ้าใส่ path รูปมา ให้ลองสกัดจริง ---------- | |
| if len(sys.argv) > 1: | |
| img = sys.argv[1] | |
| print(f'\n [3] ลองสกัดจากรูปจริง: {img}') | |
| try: | |
| f = image_to_features(img) | |
| print(f' ✅ ratio7 = {[round(v, 4) for v in f["ratio7"]]}') | |
| print(f' ✅ deep15 = {[round(v, 3) for v in f["deep15"][:5]]} ... (อีก 10 ตัว)') | |
| print('\n ต่อเข้า pipeline ได้เลย:') | |
| print(' from pipeline import analyze') | |
| print(' analyze(f["ratio7"], f["deep15"], height_cm=170, age=25, sex=1)') | |
| except Exception as e: | |
| print(f' ❌ {type(e).__name__}: {e}') | |
| else: | |
| print('\n [3] ไม่ได้ใส่ path รูป — ข้ามการสกัดจริง') | |
| print(' วิธีลอง: python scripts/app/features.py <path รูป>') | |
| print('\n' + '=' * 66) | |
| print(' หมายเหตุ: ถ้า detectron2 ขึ้น ❌ = เครื่องนี้รันสาย A ไม่ได้ (ปกติสำหรับ Windows)') | |
| print(' ต้องรันบน Linux/Docker เท่านั้น') | |
| print('=' * 66) | |