File size: 11,386 Bytes
ede84b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """
core/models.py
==============
ทะเบียนโมเดลรูปฝั่งเสิร์ฟ — อ่านจาก brands/<key>/brand.yaml › image (HANDOFF ข้อ 5.1)
หน้าที่เดียว: ตรวจว่า "ของที่จะโหลด ตรงกับที่ประกาศ" ก่อนระบบเริ่มตอบ
ไม่ตรงให้หยุดพร้อมบอกค่าทั้งสองฝั่ง ห้ามโหลดต่อแล้วไปพังตอนตอบคำถาม
(บทเรียนจาก expand_products ที่คืนค่าว่างเงียบ ๆ)
สิ่งที่ตรวจ
labels จำนวนคลาสอ่านจากไฟล์ ไม่ฝังในโค้ด · id ต้องเรียง 0..n-1 ไม่ขาด
checkpoint จำนวนคลาสที่ checkpoint มีจริง อ่านจากหัวไฟล์ safetensors (ไม่ต้องใช้ torch)
ต้องเท่ากับ labels
revision adapter ในเครื่อง → "sha256:<16 ตัวแรก>" ของไฟล์น้ำหนัก ต้องตรง
adapter บน Hub → ต้องระบุ tag หรือ commit ห้ามว่าง ห้าม main/master
thresholds ไฟล์ JSON ค่าตัวเลขอยู่ในช่วง 0..1 (ช่อง "source" เป็นข้อความได้)
ไม่ import torch / qdrant — collection ตรวจผ่านฟังก์ชันที่ผู้เรียกส่งมา
MLflow ไม่เกี่ยวกับไฟล์นี้: ฝั่งเสิร์ฟอ่านแค่ไฟล์ตั้งค่า (ข้อ 5)
"""
from __future__ import annotations
import hashlib
import json
import os
import struct
from dataclasses import dataclass, field
from typing import Callable, Optional
FLOATING_REVISIONS = {"", "main", "master", "latest", "head"}
WEIGHT_FILE = "adapter_model.safetensors"
class ModelSpecError(RuntimeError):
"""ของที่ประกาศไม่ตรงกับของที่มีจริง — ข้อความต้องบอกทั้งสองค่าเสมอ"""
@dataclass(frozen=True)
class ImageModelSpec:
brand: str
base: str
adapter: str # พาธเต็มของโฟลเดอร์ หรือชื่อ repo บน Hub
adapter_is_local: bool
revision: str
labels: tuple[str, ...] # เรียงตาม id
thresholds: dict
collection: str
reject_th: str = ""
groups_path: Optional[str] = None # visual_groups.json ถ้ามี
@property
def num_labels(self) -> int:
return len(self.labels)
@dataclass
class ImageStatus:
"""ผลการตัดสินว่าแบรนด์นี้รับรูปไหม — enabled: auto ต้องบอกเหตุผลเสมอ"""
enabled: bool
reason: str
spec: Optional[ImageModelSpec] = None
problems: list = field(default_factory=list)
# ─────────────────────────── อ่านไฟล์ ───────────────────────────
def _resolve(brand_dir: str, p: Optional[str]) -> Optional[str]:
if not p:
return None
return p if os.path.isabs(p) else os.path.normpath(os.path.join(brand_dir, p))
def read_labels(path: str) -> tuple[str, ...]:
"""รับทั้ง label_mapping.json แบบ {"id2label": {...}} และลิสต์ธรรมดา"""
with open(path, encoding="utf-8") as f:
raw = json.load(f)
if isinstance(raw, list):
return tuple(str(x) for x in raw)
id2 = raw.get("id2label") if isinstance(raw, dict) else None
if not isinstance(id2, dict) or not id2:
raise ModelSpecError(f"{path}: ไม่พบ id2label หรือว่าง")
ids = sorted(int(k) for k in id2)
if ids != list(range(len(ids))):
raise ModelSpecError(f"{path}: id ของคลาสต้องเป็น 0..{len(ids) - 1} ต่อเนื่อง แต่ได้ {ids}")
return tuple(str(id2[str(i)]) for i in ids)
def checkpoint_num_labels(adapter_dir: str) -> Optional[int]:
"""จำนวนคลาสใน classifier ของ checkpoint — อ่านจากหัวไฟล์ safetensors อย่างเดียว ไม่โหลดน้ำหนัก
คืน None ถ้าไฟล์ไม่มี classifier (เช่น adapter ที่ไม่ได้ save หัวจำแนก)"""
path = os.path.join(adapter_dir, WEIGHT_FILE)
with open(path, "rb") as f:
n = struct.unpack("<Q", f.read(8))[0]
header = json.loads(f.read(n))
for k, v in header.items():
if k.endswith("classifier.bias") and isinstance(v, dict):
return int(v["shape"][0])
for k, v in header.items():
if k.endswith("classifier.weight") and isinstance(v, dict):
return int(v["shape"][0])
return None
def file_revision(adapter_dir: str) -> str:
h = hashlib.sha256()
with open(os.path.join(adapter_dir, WEIGHT_FILE), "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()[:16]
def read_thresholds(path: str) -> dict:
with open(path, encoding="utf-8") as f:
th = json.load(f)
if not isinstance(th, dict) or not th:
raise ModelSpecError(f"{path}: ต้องเป็น object ที่มีค่าอย่างน้อยหนึ่งค่า")
for k, v in th.items():
if k == "source":
continue
if not isinstance(v, (int, float)) or isinstance(v, bool) or not 0.0 <= v <= 1.0:
raise ModelSpecError(f"{path}: {k} = {v!r} ต้องเป็นตัวเลข 0..1")
return th
# ─────────────────────────── ตรวจ ───────────────────────────
def load_image_spec(brand_key: str, brand_dir: str, image_cfg: dict) -> ImageModelSpec:
"""อ่านและตรวจครบทุกข้อ — ผิดข้อไหนหยุดที่ข้อนั้นพร้อมบอกค่าทั้งสองฝั่ง"""
vit = image_cfg.get("vit") or {}
where = f"brands/{brand_key}/brand.yaml › image"
missing = [k for k, v in (("vit.base", vit.get("base")), ("vit.adapter", vit.get("adapter")),
("vit.revision", vit.get("revision")), ("labels", image_cfg.get("labels")),
("thresholds", image_cfg.get("thresholds")),
("collection", image_cfg.get("collection"))) if not v]
if missing:
raise ModelSpecError(f"{where}: ยังไม่ได้กำหนด {', '.join(missing)}")
adapter = _resolve(brand_dir, vit["adapter"])
is_local = os.path.isdir(adapter)
revision = str(vit["revision"]).strip()
if is_local:
actual = file_revision(adapter)
if revision != actual:
raise ModelSpecError(f"{where}.vit.revision: ประกาศ {revision} แต่ไฟล์ใน {adapter} คือ {actual}")
else:
adapter = vit["adapter"] # ชื่อ repo — ใช้ตามที่เขียน
if revision.lower() in FLOATING_REVISIONS:
raise ModelSpecError(f"{where}.vit.revision: '{revision}' เป็นค่าลอย ต้องเป็น tag หรือ commit")
labels = read_labels(_resolve(brand_dir, image_cfg["labels"]))
if is_local:
ck = checkpoint_num_labels(adapter)
if ck is not None and ck != len(labels):
raise ModelSpecError(f"{where}: labels มี {len(labels)} คลาส แต่ checkpoint {adapter} มี {ck} คลาส")
thresholds = read_thresholds(_resolve(brand_dir, image_cfg["thresholds"]))
return ImageModelSpec(
brand=brand_key, base=vit["base"], adapter=adapter, adapter_is_local=is_local,
revision=revision, labels=labels, thresholds=thresholds,
collection=str(image_cfg["collection"]),
reject_th=" ".join(str(image_cfg.get("reject_th") or "").split()),
groups_path=_resolve(brand_dir, image_cfg.get("visual_groups")),
)
def resolve_image(brand_key: str, brand_dir: str, image_cfg: Optional[dict],
collection_points: Optional[Callable[[str], int]] = None) -> ImageStatus:
"""
enabled: false → ปิด
enabled: true → ต้องครบทุกข้อ ไม่ครบ = ModelSpecError (หยุดระบบ)
enabled: auto → เปิดเมื่อโมเดลตรวจผ่าน และ collection มีข้อมูล ไม่ครบ = ปิดพร้อมเหตุผล
ถ้าผู้เรียกไม่ส่ง collection_points มา (เช่น ชุดเกตที่ไม่มี Qdrant)
ถือว่ายังยืนยันไม่ได้ → ปิด
"""
if not image_cfg:
return ImageStatus(False, "ไม่มีหัวข้อ image")
mode = image_cfg.get("enabled", "auto")
mode = {True: "true", False: "false"}.get(mode, str(mode).lower())
if mode not in ("true", "false", "auto"):
raise ModelSpecError(f"brands/{brand_key}/brand.yaml › image.enabled: '{mode}' ต้องเป็น true/false/auto")
if mode == "false":
return ImageStatus(False, "ปิดใน brand.yaml")
try:
spec = load_image_spec(brand_key, brand_dir, image_cfg)
except (ModelSpecError, OSError, ValueError) as e:
if mode == "true":
raise ModelSpecError(str(e)) from e
return ImageStatus(False, f"auto: โมเดลยังไม่พร้อม — {e}", problems=[str(e)])
if collection_points is None:
if mode == "true":
return ImageStatus(True, "เปิดใน brand.yaml (ยังไม่ได้ตรวจ collection)", spec)
return ImageStatus(False, "auto: ไม่ได้ตรวจ collection — ยืนยันไม่ได้ว่ามีข้อมูล", spec)
try:
n = int(collection_points(spec.collection))
except Exception as e: # noqa: BLE001
n, err = 0, f"{type(e).__name__}: {e}"
else:
err = ""
if n <= 0:
msg = f"collection {spec.collection} ไม่มีข้อมูล" + (f" ({err})" if err else "")
if mode == "true":
raise ModelSpecError(f"brands/{brand_key}/brand.yaml › image: เปิดไว้แต่{msg}")
return ImageStatus(False, f"auto: {msg}", spec)
return ImageStatus(True, f"{mode}: โมเดลตรงที่ประกาศ · collection {spec.collection} มี {n} จุด", spec)
|