| from __future__ import annotations |
|
|
| import io |
| import base64 |
| from pathlib import Path |
| from typing import Union |
| from dataclasses import dataclass, field |
|
|
| import numpy as np |
| from PIL import Image, ExifTags |
| from loguru import logger |
|
|
|
|
| @dataclass |
| class ImageInput: |
| """Normalized image container — semua sumber dikonversi ke sini.""" |
| pil_image: Image.Image |
| original_size: tuple[int, int] |
| source: str = "unknown" |
| filename: str = "" |
| format: str = "RGB" |
| metadata: dict = field(default_factory=dict) |
|
|
| @property |
| def width(self) -> int: |
| return self.pil_image.width |
|
|
| @property |
| def height(self) -> int: |
| return self.pil_image.height |
|
|
| @property |
| def numpy(self) -> np.ndarray: |
| """Return as HWC uint8 numpy array (untuk OpenCV/YOLO).""" |
| return np.array(self.pil_image) |
|
|
| def to_base64(self) -> str: |
| buf = io.BytesIO() |
| self.pil_image.save(buf, format="JPEG", quality=85) |
| return base64.b64encode(buf.getvalue()).decode() |
|
|
|
|
| class ImagePreprocessor: |
| """ |
| Handle semua bentuk input gambar. |
| |
| _from_url menggunakan per-phase timeout agresif: |
| connect: 5s — kalau server ga response dalam 5s, skip |
| read: 8s — kalau TTFB lambat (CDN throttle), skip |
| total: ~13s max |
| |
| Ini mencegah CDN seperti Getty Images yang nge-block server |
| requests dari HF container IPs bikin seluruh pipeline hang. |
| """ |
|
|
| MAX_SIZE = (1920, 1920) |
| MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024 |
|
|
| @classmethod |
| def load(cls, source: Union[str, bytes, Path, Image.Image]) -> ImageInput: |
| if isinstance(source, Image.Image): |
| return cls._from_pil(source, source_name="pil_direct") |
| if isinstance(source, bytes): |
| return cls._from_bytes(source) |
| if isinstance(source, Path) or ( |
| isinstance(source, str) and not source.startswith(("http", "data:")) |
| ): |
| return cls._from_file(str(source)) |
| if isinstance(source, str) and source.startswith("data:image"): |
| return cls._from_base64(source) |
| if isinstance(source, str) and source.startswith(("http://", "https://")): |
| return cls._from_url(source) |
| raise ValueError(f"Tipe input tidak dikenali: {type(source)}") |
|
|
| @classmethod |
| def _from_file(cls, path: str) -> ImageInput: |
| p = Path(path) |
| if not p.exists(): |
| raise FileNotFoundError(f"Gambar tidak ditemukan: {path}") |
| img = Image.open(p) |
| img = cls._normalize(img) |
| return ImageInput( |
| pil_image=img, |
| original_size=(img.width, img.height), |
| source="file", |
| filename=p.name, |
| metadata={"path": str(p), "format": p.suffix}, |
| ) |
|
|
| @classmethod |
| def _from_bytes(cls, data: bytes, filename: str = "upload") -> ImageInput: |
| img = Image.open(io.BytesIO(data)) |
| original_size = (img.width, img.height) |
| img = cls._normalize(img) |
| return ImageInput( |
| pil_image=img, |
| original_size=original_size, |
| source="bytes", |
| filename=filename, |
| metadata={"size_bytes": len(data)}, |
| ) |
|
|
| @classmethod |
| def _from_base64(cls, b64_str: str) -> ImageInput: |
| if "," in b64_str: |
| b64_str = b64_str.split(",", 1)[1] |
| data = base64.b64decode(b64_str) |
| return cls._from_bytes(data, filename="base64_input") |
|
|
| @classmethod |
| def _from_url(cls, url: str) -> ImageInput: |
| import httpx |
|
|
| logger.debug(f"Fetching image from URL: {url}") |
|
|
| |
| |
| |
| |
| |
| timeout = httpx.Timeout(connect=8.0, read=15.0, write=5.0, pool=2.0) |
|
|
| headers = { |
| "User-Agent": ( |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " |
| "AppleWebKit/537.36 (KHTML, like Gecko) " |
| "Chrome/124.0.0.0 Safari/537.36" |
| ), |
| "Accept": "image/webp,image/jpeg,image/png,image/*,*/*;q=0.8", |
| "Accept-Language": "id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7", |
| "Referer": "https://www.google.com/", |
| } |
|
|
| try: |
| with httpx.Client(timeout=timeout, follow_redirects=True, max_redirects=3) as client: |
| with client.stream("GET", url, headers=headers) as resp: |
| resp.raise_for_status() |
|
|
| content_type = resp.headers.get("content-type", "") |
| if "html" in content_type or "text" in content_type: |
| raise ValueError( |
| f"URL mengembalikan {content_type} bukan gambar. " |
| "Pastikan URL langsung ke file gambar (jpg/png/webp)." |
| ) |
|
|
| chunks = [] |
| total = 0 |
| for chunk in resp.iter_bytes(chunk_size=65536): |
| total += len(chunk) |
| if total > cls.MAX_DOWNLOAD_BYTES: |
| raise ValueError( |
| f"Gambar terlalu besar (>{cls.MAX_DOWNLOAD_BYTES//1024//1024}MB)" |
| ) |
| chunks.append(chunk) |
|
|
| data = b"".join(chunks) |
|
|
| except httpx.ConnectTimeout: |
| raise RuntimeError( |
| f"Tidak bisa connect ke server gambar dalam 8s. " |
| "CDN ini kemungkinan memblok request dari server HF (US). " |
| "Coba upload gambar langsung (tab Upload) atau pakai URL dari " |
| "imgur.com, ibb.co, atau raw GitHub." |
| ) |
| except httpx.ReadTimeout: |
| raise RuntimeError( |
| "Server gambar merespons terlalu lambat (>15s). " |
| "CDN lokal Indonesia sering throttle request dari server HF di US. " |
| "Coba upload gambar langsung atau pakai URL dari imgur/ibb.co." |
| ) |
| except httpx.HTTPStatusError as e: |
| raise RuntimeError( |
| f"Server gambar mengembalikan error {e.response.status_code}. " |
| "Pastikan URL gambar valid dan publik." |
| ) |
| except httpx.HTTPError as e: |
| raise RuntimeError(f"Gagal mengunduh gambar: {e}") |
|
|
| try: |
| img_input = cls._from_bytes(data, filename=url.split("/")[-1].split("?")[0] or "url_image") |
| except Exception as e: |
| raise ValueError( |
| f"File yang diunduh bukan gambar yang valid: {e}. " |
| "Pastikan URL mengarah langsung ke file gambar." |
| ) |
|
|
| img_input.source = "url" |
| img_input.metadata["url"] = url |
| logger.info(f"Downloaded image: {total} bytes → {img_input.width}x{img_input.height}") |
| return img_input |
|
|
| @classmethod |
| def _from_pil(cls, img: Image.Image, source_name: str = "pil") -> ImageInput: |
| original_size = (img.width, img.height) |
| img = cls._normalize(img) |
| return ImageInput(pil_image=img, original_size=original_size, source=source_name) |
|
|
| @classmethod |
| def _normalize(cls, img: Image.Image) -> Image.Image: |
| """Convert ke RGB, fix EXIF rotation, resize jika terlalu besar.""" |
| try: |
| exif = img._getexif() |
| if exif: |
| for tag, val in exif.items(): |
| if ExifTags.TAGS.get(tag) == "Orientation": |
| rotations = {3: 180, 6: 270, 8: 90} |
| if val in rotations: |
| img = img.rotate(rotations[val], expand=True) |
| except Exception: |
| pass |
| if img.mode != "RGB": |
| img = img.convert("RGB") |
| if img.width > cls.MAX_SIZE[0] or img.height > cls.MAX_SIZE[1]: |
| img.thumbnail(cls.MAX_SIZE, Image.LANCZOS) |
| logger.debug(f"Resized image to {img.width}x{img.height}") |
| return img |
|
|