|
Download SKILL.md from PhonePixelGhost/Image_Classification_Service: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/PhonePixelGhost/Image_Classification_Service/resolve/main/SKILL.md
- Command line
-
hf download hf://spaces/PhonePixelGhost/Image_Classification_Service/SKILL.md
-
curl -L -o SKILL.md https://huggingface.co/spaces/PhonePixelGhost/Image_Classification_Service/resolve/main/SKILL.md
15.4 kB
| 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/<YOUR_USERNAME>/<YOUR_SPACE_NAME> | |
| 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://<space>.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://<USERNAME>-<SPACE>.hf.space/health | |
| # Predict (ส่งไฟล์รูปภาพจริง) | |
| curl -X POST "https://<USERNAME>-<SPACE>.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=.` ให้ถูกต้อง | |