import os import io import uuid import shutil import torch import numpy as np import asyncio import logging import time from PIL import Image from pathlib import Path from typing import List, Dict from contextlib import asynccontextmanager from fastapi import FastAPI, UploadFile, File, HTTPException, Depends from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse, HTMLResponse, FileResponse import ollama from transformers import AutoModel # --- Config & Logging --- logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" BASE_DIR = Path(__file__).parent.resolve() UPLOADS_DIR = BASE_DIR / "uploads" STATIC_DIR = BASE_DIR / "static" MAX_FILE_SIZE = 20 * 1024 * 1024 # 20MB ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} SESSION_EXPIRY = 3600 # 1 hour OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5vl:3b") OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434") # กำหนด timeout เป็น 300.0 วินาที (5 นาที) สำหรับกรณีรันบน CPU หรือระบบที่มีทรัพยากรจำกัด ollama_client = ollama.AsyncClient(host=OLLAMA_HOST, timeout=300.0) # Magic Bytes for file validation MAGIC_BYTES = { b"\x89PNG": "png", b"\xff\xd8\xff": "jpeg", b"RIFF": "webp", # WebP files start with RIFF...WEBP } # Ensure directories exist UPLOADS_DIR.mkdir(exist_ok=True) STATIC_DIR.mkdir(exist_ok=True) # Global models models: Dict = {} async def periodic_cleanup_task(): """Background task to clean up expired sessions regularly.""" while True: try: logger.info("🧹 Starting periodic session cleanup...") now = time.time() if UPLOADS_DIR.exists(): for item in UPLOADS_DIR.iterdir(): if item.is_dir(): # Check directory modification time mtime = item.stat().st_mtime if now - mtime > SESSION_EXPIRY: logger.info(f"🧹 Cleaning up expired session: {item.name}") # Delete in a thread pool to avoid blocking the event loop await asyncio.to_thread(shutil.rmtree, item) except Exception as e: logger.error(f"❌ Error in periodic cleanup task: {e}") # Run cleanup every 10 minutes await asyncio.sleep(600) @asynccontextmanager async def lifespan(app: FastAPI): # --- Load Models --- try: logger.info("🚀 กำลังโหลดโมเดล MAGI...") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") logger.info(f"💻 ใช้ฮาร์ดแวร์อุปกรณ์: {device}") models["magi"] = AutoModel.from_pretrained( "ragavsachdeva/magi", trust_remote_code=True ).to(device) models["magi"].eval() logger.info("✅ โหลดโมเดล MAGI สำเร็จ") except Exception as e: logger.error(f"❌ Failed to load models: {e}") # โยน Exception เพื่อให้โปรแกรม Crash-on-startup (Fail-fast) จะได้รู้ว่าโมเดลโหลดไม่ผ่าน raise RuntimeError(f"Could not load required models: {e}") # --- ตรวจสอบสถานะการเชื่อมต่อของ Ollama --- try: logger.info(f"🔍 กำลังตรวจสอบการเชื่อมต่อกับ Ollama Server ที่ {OLLAMA_HOST}...") # ใช้ timeout สั้นๆ สำหรับขั้นตอน Startup เพื่อไม่ให้โปรแกรมค้างหาก Ollama ยังไม่ได้เปิด temp_client = ollama.AsyncClient(host=OLLAMA_HOST, timeout=3.0) models_list = await temp_client.list() logger.info("✅ เชื่อมต่อกับ Ollama Server สำเร็จ!") # ตรวจสอบว่าโมเดลพร้อมใช้งานหรือไม่ available_models = [m.get("name", m.get("model", "")) for m in models_list.get("models", [])] logger.info(f"📦 โมเดลที่มีใน Ollama: {available_models}") if OLLAMA_MODEL not in available_models and f"{OLLAMA_MODEL}:latest" not in available_models: logger.warning( f"⚠️ ไม่พบโมเดล '{OLLAMA_MODEL}' ใน Ollama Server! " f"กรุณารันคำสั่ง 'ollama pull {OLLAMA_MODEL}' ใน Terminal ก่อนเริ่มวิเคราะห์" ) except Exception as e: logger.error( f"❌ ไม่สามารถเชื่อมต่อกับ Ollama Server ได้ ({e}) " f"โปรดตรวจสอบว่า Ollama Server กำลังรันอยู่บนโฮสต์ {OLLAMA_HOST} และโมเดลติดตั้งไว้แล้ว" ) # Start periodic cleanup as a background task cleanup_task = asyncio.create_task(periodic_cleanup_task()) yield # Cancel background cleanup cleanup_task.cancel() try: await cleanup_task except asyncio.CancelledError: pass # --- Cleanup --- logger.info("🧹 Cleaning up models...") models.clear() if torch.cuda.is_available(): torch.cuda.empty_cache() def get_ready_models(): """Dependency to ensure models are loaded.""" if not models.get("magi"): raise HTTPException(status_code=503, detail="Models not ready") return models app = FastAPI(lifespan=lifespan) # Mount static app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @app.get("/uploads/{session_id}/{filename}") async def serve_upload(session_id: str, filename: str): try: uuid.UUID(session_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid session ID") # Sanitize filename to prevent Path Traversal filename = Path(filename).name file_path = (UPLOADS_DIR / session_id / filename).resolve() try: # Verify that the path is actually inside UPLOADS_DIR file_path.relative_to(UPLOADS_DIR.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path) def validate_magic_bytes(content: bytes): """Verify file type using magic bytes.""" for magic in MAGIC_BYTES.keys(): if content.startswith(magic): return True raise HTTPException( status_code=400, detail="Invalid file content: Magic bytes do not match allowed image types.", ) def validate_upload_meta(file: UploadFile): ext = Path(file.filename or "").suffix.lower() if ext not in ALLOWED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Unsupported file type: {ext}. Allowed: {ALLOWED_EXTENSIONS}", ) async def get_qwen_description(image_crop: Image.Image, panel_index: int) -> str: # ขยายภาพ 2 เท่า ด้วย LANCZOS เพื่อรักษาความคมชัดของภาพมังงะและตัวอักษรขนาดเล็ก width, height = image_crop.size # ดึงการปรับขนาดรูปแบบ PIL ออกไปทำนอก Thread หลักเพื่อป้องกันความเร็วตก upscaled_image = await asyncio.to_thread( image_crop.resize, (width * 2, height * 2), Image.LANCZOS ) img_byte_arr = io.BytesIO() await asyncio.to_thread(upscaled_image.save, img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() prompt = f"""นี่คือกรอบมังงะช่องที่ {panel_index} จงเขียน 'บทอ่านออกเสียง' (Script for Reading) สำหรับเนื้อหาในภาพนี้ โดยมีเงื่อนไขดังนี้: 1. ให้บรรยายสถานการณ์และบทสนทนาให้เป็นเนื้อความเดียวกันแบบนิยาย 2. ไม่ต้องมีหัวข้อ (เช่น ไม่ต้องมี 1. 2. 3. หรือ คำว่า 'สถานการณ์:') 3. ไม่ต้องมีสัญลักษณ์พิเศษ หรือเครื่องหมายคำพูดที่ซับซ้อน 4. ใช้ภาษาไทยที่สละสลวย อ่านแล้วเข้าใจทันทีว่าใครพูดอะไร หรือเกิดอะไรขึ้น 5. เน้นเนื้อหาที่จะนำไปใช้กับระบบ Text-to-Speech (อ่านออกเสียง) ได้ทันที """ logger.info(f"🤖 [Panel {panel_index}] กำลังส่งคำร้องไปยัง Ollama ({OLLAMA_MODEL})...") start_time = time.time() try: response = await ollama_client.chat( model=OLLAMA_MODEL, messages=[{"role": "user", "content": prompt, "images": [img_bytes]}], options={ "num_predict": 250, # จำกัดจำนวน token คำตอบ เพื่อเร่งความเร็วบน CPU "temperature": 0.2, } ) elapsed = time.time() - start_time logger.info(f"✅ [Panel {panel_index}] ได้รับคำอธิบายจาก Ollama แล้ว (ใช้เวลา: {elapsed:.2f} วินาที)") return response["message"]["content"].strip() except Exception as e: elapsed = time.time() - start_time logger.error(f"❌ [Panel {panel_index}] เกิดข้อผิดพลาดในการเชื่อมต่อ Ollama (หลัง {elapsed:.2f} วินาที): {e}") return f"Error ในการบรรยาย: {e}" @app.get("/") async def read_index(): index_path = STATIC_DIR / "index.html" if not index_path.exists(): return HTMLResponse(content="