"""
轻量级视觉模型服务器 - Moondream2 集成(优化版)
包含图片大小保护、超时控制、并发限制
"""
import asyncio
import base64
import json
import os
import sys
import time
import threading
from pathlib import Path
from typing import Dict, Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import uvicorn
from PIL import Image
MAX_FILE_SIZE = 5 * 1024 * 1024
MAX_IMAGE_SIZE = (1920, 1080)
INFERENCE_TIMEOUT = 30
MAX_CONCURRENT_REQUESTS = 2
_model = None
_model_loaded = False
_current_requests = 0
_request_lock = asyncio.Lock()
class AnalysisRequest(BaseModel):
"""视觉分析请求"""
image_path: Optional[str] = None
image_base64: Optional[str] = None
prompt: str = "Describe this image in detail."
output_format: str = "text"
class AnalysisResponse(BaseModel):
"""视觉分析响应"""
text: str
data: Optional[Dict[str, Any]] = None
confidence: Optional[float] = None
processing_time_ms: int
app = FastAPI(title="LoopAgent Vision Service")
def load_model():
"""延迟加载 Moondream2 模型"""
global _model, _model_loaded
if _model_loaded:
return _model
try:
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
print("[Vision] Loading Moondream2 model...", file=sys.stderr)
model_id = "vikhyatk/moondream2"
_model = {
"model": AutoModelForCausalLM.from_pretrained(
model_id,
trust_remote_code=True,
torch_dtype=torch.float32,
low_cpu_mem_usage=True
),
"tokenizer": AutoTokenizer.from_pretrained(model_id)
}
_model_loaded = True
print("[Vision] Model loaded successfully", file=sys.stderr)
return _model
except Exception as e:
print(f"[Vision] Failed to load model: {e}", file=sys.stderr)
raise HTTPException(status_code=500, detail=f"Model loading failed: {str(e)}")
def resize_image_if_needed(image: Image.Image, max_size: tuple) -> Image.Image:
"""
如果图片超过最大尺寸,按比例缩小
保持宽高比,使用高质量重采样
"""
width, height = image.size
max_width, max_height = max_size
if width <= max_width and height <= max_height:
return image
ratio = min(max_width / width, max_height / height)
new_width = int(width * ratio)
new_height = int(height * ratio)
print(f"[Vision] Resizing image: {width}×{height} → {new_width}×{new_height}", file=sys.stderr)
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
class TimeoutException(Exception):
pass
def run_with_timeout(func, args=(), kwargs=None, timeout_seconds=30):
"""跨平台超时执行(使用线程)"""
if kwargs is None:
kwargs = {}
result = [TimeoutException(f"Timeout after {timeout_seconds}s")]
def target():
try:
result[0] = func(*args, **kwargs)
except Exception as e:
result[0] = e
thread = threading.Thread(target=target)
thread.daemon = True
thread.start()
thread.join(timeout_seconds)
if thread.is_alive():
raise TimeoutException(f"Inference timeout after {timeout_seconds}s (image too large?)")
if isinstance(result[0], Exception):
raise result[0]
return result[0]
@app.get("/health")
async def health_check():
"""健康检查端点"""
return {
"status": "healthy",
"model_loaded": _model_loaded,
"provider": "moondream2",
"max_image_size": MAX_IMAGE_SIZE,
"max_file_size_mb": MAX_FILE_SIZE / 1024 / 1024
}
@app.post("/analyze", response_model=AnalysisResponse)
async def analyze_image(request: AnalysisRequest):
"""分析图片端点(带保护)"""
global _current_requests
async with _request_lock:
if _current_requests >= MAX_CONCURRENT_REQUESTS:
raise HTTPException(
status_code=429,
detail=f"Too many concurrent requests (max {MAX_CONCURRENT_REQUESTS})"
)
_current_requests += 1
start_time = time.time()
try:
if request.image_path:
if not os.path.exists(request.image_path):
raise HTTPException(status_code=404, detail="Image file not found")
file_size = os.path.getsize(request.image_path)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"Image too large: {file_size / 1024 / 1024:.1f}MB (max {MAX_FILE_SIZE / 1024 / 1024:.0f}MB)"
)
image = Image.open(request.image_path).convert("RGB")
elif request.image_base64:
image_data = base64.b64decode(request.image_base64)
if len(image_data) > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"Image data too large (max {MAX_FILE_SIZE / 1024 / 1024:.0f}MB)"
)
import io
image = Image.open(io.BytesIO(image_data)).convert("RGB")
else:
raise HTTPException(status_code=400, detail="Either image_path or image_base64 required")
original_size = image.size
image = resize_image_if_needed(image, MAX_IMAGE_SIZE)
if image.size != original_size:
print(f"[Vision] Resized from {original_size} to {image.size}", file=sys.stderr)
model_dict = load_model()
model = model_dict["model"]
tokenizer = model_dict["tokenizer"]
def inference():
enc_image = model.encode_image(image)
return model.answer_question(enc_image, request.prompt, tokenizer)
response_text = run_with_timeout(inference, timeout_seconds=INFERENCE_TIMEOUT)
processing_time = int((time.time() - start_time) * 1000)
return AnalysisResponse(
text=response_text,
data=None,
confidence=None,
processing_time_ms=processing_time
)
except TimeoutException as e:
raise HTTPException(status_code=504, detail=str(e))
except HTTPException:
raise
except Exception as e:
print(f"[Vision] Analysis error: {e}", file=sys.stderr)
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
finally:
async with _request_lock:
_current_requests -= 1
@app.on_event("shutdown")
async def shutdown_event():
"""清理资源"""
global _model, _model_loaded
if _model:
print("[Vision] Shutting down model...", file=sys.stderr)
_model = None
_model_loaded = False
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Vision model server")
parser.add_argument("--port", type=int, default=8765, help="Server port")
parser.add_argument("--host", type=str, default="127.0.0.1", help="Server host")
parser.add_argument("--max-size", type=int, default=1920, help="Max image width")
args = parser.parse_args()
if args.max_size:
MAX_IMAGE_SIZE = (args.max_size, int(args.max_size * 9 / 16))
print(f"[Vision] Starting server on {args.host}:{args.port}", file=sys.stderr)
print(f"[Vision] Max image size: {MAX_IMAGE_SIZE}", file=sys.stderr)
print(f"[Vision] Max file size: {MAX_FILE_SIZE / 1024 / 1024:.0f}MB", file=sys.stderr)
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level="warning",
access_log=False
)