--- name: image-classification-mlops description: > ทักษะสำหรับพัฒนาระบบ High-Throughput Image Classification Service ครบวงจร ตั้งแต่ Model Optimization, FastAPI Development, CI/CD Pipeline จนถึง Performance Testing โดยใช้โมเดล microsoft/resnet-18 จาก Hugging Face ใช้ skill นี้เมื่อ: - ต้องการ Optimize โมเดล (ONNX Conversion + Dynamic Quantization) - สร้าง FastAPI ที่รองรับ Concurrent Request ด้วย ProcessPoolExecutor - เขียน Dockerfile สำหรับ Production - ตั้งค่า GitHub Actions CI/CD → Deploy ไป Hugging Face Spaces - เขียน pytest Unit Tests สำหรับ /predict endpoint - วิเคราะห์ผล JMeter Load Test (Throughput / P95 Latency) - เขียน Project Report หรือสร้าง System Architecture Diagram --- # High-Throughput Image Classification Service — MLOps Skill ## ภาพรวมโปรเจกต์ | Phase | เนื้อหา | |---|---| | 1. Model Optimization | ResNet-18 → ONNX → Dynamic Quantization | | 2. API Development | FastAPI + ProcessPoolExecutor + Pydantic | | 3. Automation & CI/CD | pytest + GitHub Actions + HF Spaces Deploy | | 4. Performance Testing | JMeter Load Test + TPS/P95 Analysis | **โมเดลหลัก:** `microsoft/resnet-18` (Hugging Face) **Stack:** Python 3.11, FastAPI, ONNX Runtime, Transformers, Docker, GitHub Actions --- ## Phase 1 — Model Optimization ### 1.1 Baseline Test (Original PyTorch) ```python from transformers import AutoFeatureExtractor, ResNetForImageClassification import torch, time, os from PIL import Image model_id = "microsoft/resnet-18" extractor = AutoFeatureExtractor.from_pretrained(model_id) model = ResNetForImageClassification.from_pretrained(model_id) model.eval() # วัด Baseline Latency (100 runs) img = Image.open("test.jpg").convert("RGB") inputs = extractor(images=img, return_tensors="pt") times = [] with torch.no_grad(): for _ in range(100): t0 = time.perf_counter() _ = model(**inputs) times.append(time.perf_counter() - t0) print(f"Baseline Latency (avg): {sum(times)/len(times)*1000:.2f} ms") print(f"Model Size: {os.path.getsize('pytorch_model.bin')/1e6:.2f} MB") ``` ### 1.2 Export to ONNX ```python import torch from transformers import AutoFeatureExtractor, ResNetForImageClassification model_id = "microsoft/resnet-18" extractor = AutoFeatureExtractor.from_pretrained(model_id) model = ResNetForImageClassification.from_pretrained(model_id).eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "resnet18.onnx", input_names=["pixel_values"], output_names=["logits"], dynamic_axes={"pixel_values": {0: "batch_size"}}, opset_version=17, ) print("ONNX exported successfully") ``` ### 1.3 Dynamic Quantization ```python from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_input="resnet18.onnx", model_output="resnet18_quantized.onnx", weight_type=QuantType.QUint8, ) print("Quantization complete") ``` ### 1.4 ตารางเปรียบเทียบ (บันทึกผลจริงลงตาราง) | Format | File Size (MB) | Avg Latency (ms) | P95 Latency (ms) | |---|---|---|---| | Original (PyTorch) | ~45 | baseline | baseline | | ONNX | ~45 | คาดว่าเร็วขึ้น ~20% | - | | ONNX Quantized | ~12 | คาดว่าเร็วขึ้น ~40% | - | > **วิธีวัด:** รัน 100 ครั้ง → เก็บค่า avg และ percentile ด้วย `numpy.percentile(times, 95)` --- ## Phase 2 — API Development ### 2.1 โครงสร้างโปรเจกต์ ``` image-classification-service/ ├── app/ │ ├── main.py # FastAPI app │ ├── model.py # ONNX inference logic │ └── schemas.py # Pydantic models ├── models/ │ └── resnet18_quantized.onnx ├── tests/ │ └── test_api.py ├── .github/ │ └── workflows/ │ └── ci-cd.yml ├── Dockerfile ├── requirements.txt └── README.md ``` ### 2.2 Pydantic Schemas (`app/schemas.py`) ```python from pydantic import BaseModel from typing import Optional class PredictionResponse(BaseModel): label: str score: float label_id: int inference_time_ms: float class ErrorResponse(BaseModel): detail: str error_code: str ``` ### 2.3 ONNX Inference (`app/model.py`) ```python import onnxruntime as ort import numpy as np from PIL import Image import io, time # Labels จาก ImageNet from transformers import AutoFeatureExtractor extractor = AutoFeatureExtractor.from_pretrained("microsoft/resnet-18") # โหลด session ครั้งเดียว (module-level) session = ort.InferenceSession( "models/resnet18_quantized.onnx", providers=["CPUExecutionProvider"] ) def run_inference(image_bytes: bytes) -> dict: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") inputs = extractor(images=img, return_tensors="np") pixel_values = inputs["pixel_values"].astype(np.float32) t0 = time.perf_counter() outputs = session.run(["logits"], {"pixel_values": pixel_values}) elapsed = (time.perf_counter() - t0) * 1000 logits = outputs[0][0] probs = np.exp(logits) / np.sum(np.exp(logits)) label_id = int(np.argmax(probs)) # ดึง label จาก model config from transformers import ResNetForImageClassification cfg = ResNetForImageClassification.from_pretrained("microsoft/resnet-18").config label = cfg.id2label.get(label_id, str(label_id)) return { "label": label, "score": float(probs[label_id]), "label_id": label_id, "inference_time_ms": round(elapsed, 3), } ``` ### 2.4 FastAPI Main App (`app/main.py`) ```python from fastapi import FastAPI, File, UploadFile, HTTPException from concurrent.futures import ProcessPoolExecutor import asyncio from app.model import run_inference from app.schemas import PredictionResponse app = FastAPI(title="ResNet-18 Image Classifier", version="1.0.0") executor = ProcessPoolExecutor(max_workers=4) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} @app.get("/health") async def health(): return {"status": "ok"} @app.post("/predict", response_model=PredictionResponse) async def predict(file: UploadFile = File(...)): # Validate content type if file.content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException( status_code=415, detail=f"Unsupported media type: {file.content_type}. Allowed: {ALLOWED_CONTENT_TYPES}" ) image_bytes = await file.read() # Validate file size if len(image_bytes) > MAX_FILE_SIZE: raise HTTPException( status_code=413, detail=f"File too large. Max size is {MAX_FILE_SIZE // 1024 // 1024} MB." ) # Validate not corrupted (try opening with PIL) try: from PIL import Image import io Image.open(io.BytesIO(image_bytes)).verify() except Exception: raise HTTPException(status_code=400, detail="Corrupted or invalid image file.") # Run CPU-bound inference in ProcessPoolExecutor (ไม่บล็อก event loop) loop = asyncio.get_event_loop() try: result = await loop.run_in_executor(executor, run_inference, image_bytes) except Exception as e: raise HTTPException(status_code=500, detail=f"Inference error: {str(e)}") return PredictionResponse(**result) ``` ### 2.5 Error Handling Summary | สถานการณ์ | HTTP Status | รายละเอียด | |---|---|---| | ไฟล์ไม่ใช่รูปภาพ | 415 Unsupported Media Type | Content-type ไม่ตรง | | ไฟล์เสีย (Corrupted) | 400 Bad Request | PIL ไม่สามารถเปิดได้ | | ไฟล์ใหญ่เกินไป | 413 Request Entity Too Large | เกิน 10MB | | Inference Error | 500 Internal Server Error | โมเดลทำงานผิดพลาด | --- ## Phase 3 — Dockerfile ```dockerfile # ใช้ slim image เพื่อลด size FROM python:3.11-slim WORKDIR /app # ติดตั้ง dependencies ก่อน (cache layer) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy โค้ดและโมเดล COPY app/ ./app/ COPY models/ ./models/ EXPOSE 7860 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"] ``` **requirements.txt:** ``` fastapi==0.111.0 uvicorn[standard]==0.29.0 python-multipart==0.0.9 onnxruntime==1.18.0 numpy==1.26.4 Pillow==10.3.0 transformers==4.41.0 torch==2.3.0 pydantic==2.7.1 pytest==8.2.0 httpx==0.27.0 ``` > **เทคนิคลด Docker Image Size:** > - ใช้ `python:3.11-slim` (ไม่ใช่ full) > - `--no-cache-dir` ใน pip > - ลบ torch ออกหลัง export ONNX (ใน production image ไม่จำเป็น) > - ใช้ `.dockerignore` เพื่อ exclude `tests/`, `.git/`, `*.pt` --- ## Phase 4 — Unit Testing (`tests/test_api.py`) ```python import pytest from fastapi.testclient import TestClient from app.main import app from pathlib import Path client = TestClient(app) # --- Helper --- def get_test_image() -> bytes: """ใช้ภาพ test จริงหรือสร้าง dummy PNG""" from PIL import Image import io img = Image.new("RGB", (224, 224), color=(128, 64, 32)) buf = io.BytesIO() img.save(buf, format="JPEG") return buf.getvalue() # --- Tests --- def test_health_endpoint(): res = client.get("/health") assert res.status_code == 200 assert res.json() == {"status": "ok"} def test_predict_returns_valid_json(): img_bytes = get_test_image() res = client.post( "/predict", files={"file": ("test.jpg", img_bytes, "image/jpeg")} ) assert res.status_code == 200 data = res.json() assert "label" in data assert "score" in data assert isinstance(data["score"], float) assert 0.0 <= data["score"] <= 1.0 def test_predict_rejects_non_image(): res = client.post( "/predict", files={"file": ("test.txt", b"not an image", "text/plain")} ) assert res.status_code == 415 def test_predict_rejects_corrupted_file(): res = client.post( "/predict", files={"file": ("bad.jpg", b"\xff\xd8corrupted", "image/jpeg")} ) assert res.status_code == 400 def test_predict_rejects_oversized_file(): huge = b"A" * (11 * 1024 * 1024) # 11MB res = client.post( "/predict", files={"file": ("big.jpg", huge, "image/jpeg")} ) assert res.status_code == 413 ``` --- ## Phase 5 — GitHub Actions CI/CD (`.github/workflows/ci-cd.yml`) ```yaml name: CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: pip install -r requirements.txt - name: Run Unit Tests run: pytest tests/ -v --tb=short deploy: needs: test # รัน deploy เฉพาะเมื่อ test ผ่านทุก case runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 - name: Push to Hugging Face Spaces env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | git config --global user.email "ci@github.com" git config --global user.name "GitHub Actions" git remote add hf https://user:${HF_TOKEN}@huggingface.co/spaces// git push hf main --force ``` > **การตั้งค่า Secret:** > ไปที่ GitHub Repo → Settings → Secrets → Actions → New secret > ชื่อ: `HF_TOKEN` | ค่า: Hugging Face Access Token (write permission) --- ## Phase 6 — Performance Testing (JMeter) ### 6.1 JMeter Test Plan (.jmx) — Key Settings | Parameter | Local (Docker) | Cloud (HF Spaces) | |---|---|---| | Threads (Users) | 10, 50, 100 | 10, 25, 50 | | Ramp-Up (sec) | 10 | 20 | | Loop Count | 100 | 50 | | Endpoint | `http://localhost:7860/predict` | `https://.hf.space/predict` | ### 6.2 Metrics ที่ต้องรายงาน | Metric | คำอธิบาย | เป้าหมาย | |---|---|---| | **Throughput (TPS)** | Request ต่อวินาที | สูงที่สุด | | **P95 Latency** | 95th percentile response time | < 2000ms | | **Error Rate** | % ที่ได้รับ error | < 1% | | **Avg Latency** | ค่าเฉลี่ย response time | ต่ำที่สุด | ### 6.3 การวิเคราะห์ผล ``` จุดที่ต้องวิเคราะห์: 1. หา "Knee Point" — จุดที่ TPS หยุดเพิ่ม แต่ Latency เริ่มพุ่ง 2. CPU Utilization ใน Docker stats ณ จำนวน concurrent users นั้น 3. เปรียบเทียบ Local vs Cloud เพื่อดู overhead ของ Network/HF cold-start ``` --- ## Phase 7 — cURL Examples ```bash # Health Check curl https://-.hf.space/health # Predict (ส่งไฟล์รูปภาพจริง) curl -X POST "https://-.hf.space/predict" \ -H "accept: application/json" \ -F "file=@/path/to/your/image.jpg" # Postman Collection — ดูไฟล์ postman_collection.json ใน repo ``` --- ## Checklist Deliverables - [ ] Project Report (PDF) — Model details, Optimization table, Error strategy, JMeter analysis, Architecture diagram - [ ] GitHub Repo — Source code + `.github/workflows/ci-cd.yml` + `README.md` - [ ] `resnet18_quantized.onnx` — โมเดลที่ optimize แล้ว - [ ] `tests/test_api.py` — pytest ครอบคลุม Happy path + Error cases - [ ] `Dockerfile` — Production-ready - [ ] JMeter Test Plan (`.jmx`) - [ ] Postman Collection (`.json`) - [ ] Hugging Face Space — Live API endpoint - [ ] Presentation Slides + Live Demo (9 พ.ค. 2569) --- ## Notes & Tips - **HF Spaces Free Tier** ใช้ CPU เท่านั้น — ONNX Runtime บน CPU เหมาะสมที่สุด - **Cold Start** ใน HF Spaces อาจทำให้ request แรกช้า — ควรระบุในรายงาน - **ProcessPoolExecutor** ต้องระวัง: แต่ละ worker โหลด ONNX session แยกกัน (memory x workers) - **Pydantic v2** syntax เปลี่ยนจาก v1 — ใช้ `model_config` แทน `class Config` - ใน `pytest` ต้องมี `conftest.py` หรือ set `PYTHONPATH=.` ให้ถูกต้อง