| import os |
| import base64 |
| import json |
| import uuid |
| import threading |
| import traceback |
| import time |
| from datetime import datetime |
| import requests |
| from io import BytesIO |
|
|
| from PIL import Image |
| from fastapi import FastAPI, HTTPException, Header |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import Response |
| from pydantic import BaseModel |
|
|
| def log(msg: str): |
| ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] |
| print(f"[{ts}] {msg}") |
|
|
| app = FastAPI() |
|
|
| |
| |
| |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| |
| |
|
|
| OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY") |
|
|
| |
| API_KEY = os.getenv("API_KEY") |
|
|
| |
| MODAL_API_URL = os.getenv("MODAL_API_URL") |
|
|
| |
| |
| |
|
|
| tasks = {} |
| image_store = {} |
|
|
| def extract_image_bytes(data: dict) -> tuple: |
| """ |
| 从 Modal 响应中提取图片 |
| 支持: |
| - image_base64 |
| - base64 |
| - image_data |
| - image_url |
| - url |
| """ |
|
|
| |
| if "image_base64" in data and data["image_base64"]: |
| return ( |
| base64.b64decode(data["image_base64"]), |
| "image/png" |
| ) |
|
|
| |
| for field in [ |
| "base64", |
| "image_data", |
| "image" |
| ]: |
| value = data.get(field) |
|
|
| if value and isinstance(value, str): |
|
|
| if "," in value: |
| value = value.split(",", 1)[1] |
|
|
| return ( |
| base64.b64decode(value), |
| "image/png" |
| ) |
|
|
| |
| for field in [ |
| "url", |
| "image_url" |
| ]: |
| value = data.get(field) |
|
|
| if value and isinstance(value, str): |
|
|
| resp = requests.get( |
| value, |
| timeout=60 |
| ) |
|
|
| resp.raise_for_status() |
|
|
| return ( |
| resp.content, |
| resp.headers.get( |
| "content-type", |
| "image/png" |
| ) |
| ) |
|
|
| |
| if isinstance(data.get("images"), list): |
|
|
| first = data["images"][0] |
|
|
| if isinstance(first, dict): |
| return extract_image_bytes(first) |
|
|
| if isinstance(data.get("output"), list): |
|
|
| first = data["output"][0] |
|
|
| if isinstance(first, dict): |
| return extract_image_bytes(first) |
|
|
| raise ValueError( |
| f"无法解析图片响应: {str(data)[:500]}" |
| ) |
|
|
| def background_generate(task_id: str, image_base64: str): |
| t_start = time.time() |
| log(f"[{task_id}] START background_generate") |
|
|
| try: |
| tasks[task_id] = {"status": "processing"} |
|
|
| t0 = time.time() |
| processed = resize_image(image_base64) |
| log(f"[{task_id}] resize_image took {time.time()-t0:.2f}s") |
|
|
| t0 = time.time() |
| vision_result = call_openrouter(processed, PROMPT_INSTRUCTION) |
| log(f"[{task_id}] call_openrouter took {time.time()-t0:.2f}s") |
|
|
| t0 = time.time() |
| prompt_text = vision_result["choices"][0]["message"]["content"] |
| log(f"[{task_id}] prompt: {prompt_text[:120]}") |
|
|
| t0 = time.time() |
| modal_response = requests.post( |
| MODAL_API_URL, |
| json={"prompt": prompt_text}, |
| timeout=600 |
| ) |
| modal_response.raise_for_status() |
| image_data = modal_response.json() |
| log(f"[{task_id}] Modal API took {time.time()-t0:.2f}s") |
|
|
| log(f"[{task_id}] ====== MODAL RESPONSE ======") |
| log(f"[{task_id}] {image_data}") |
|
|
| t0 = time.time() |
| img_bytes, content_type = extract_image_bytes(image_data) |
| log(f"[{task_id}] extract_image_bytes took {time.time()-t0:.2f}s") |
|
|
| log(f"[{task_id}] Image Size: {len(img_bytes)/1024:.1f} KB") |
|
|
| image_store[task_id] = (img_bytes, content_type) |
|
|
| tasks[task_id] = { |
| "status": "completed", |
| "result": {"image_url": f"/image/{task_id}"}, |
| "prompt": prompt_text |
| } |
|
|
| log(f"[{task_id}] COMPLETED in {time.time()-t_start:.2f}s") |
| except Exception as e: |
| tasks[task_id] = { |
| "status": "failed", |
| "error": str(e), |
| "traceback": traceback.format_exc() |
| } |
|
|
| log(f"[{task_id}] FAILED after {time.time()-t_start:.2f}s: {e}") |
| log(traceback.format_exc()) |
|
|
| |
| |
| |
|
|
| META_INSTRUCTION = """ |
| 你是专业图片分析助手。 |
| |
| 输出 JSON 格式。 |
| |
| 要求: |
| |
| 1. gender |
| 2. age_estimate |
| 3. hairstyle |
| 4. clothing |
| 5. pose |
| 6. facial_expression |
| 7. environment |
| 8. lighting |
| 9. camera_angle |
| 10. text_content |
| |
| 直接输出 JSON。 |
| 不要 Markdown。 |
| """ |
|
|
| PROMPT_INSTRUCTION = """ |
| Analyze the image and generate a high quality AI image generation prompt. |
| |
| Requirements: |
| |
| - English only |
| - Suitable for FLUX and SDXL |
| - Include: |
| subject, |
| clothing, |
| pose, |
| environment, |
| lighting, |
| camera angle, |
| artistic details |
| |
| Output only prompt text. |
| |
| No markdown. |
| No explanations. |
| """ |
|
|
| |
| |
| |
|
|
| class ImageRequest(BaseModel): |
| image_base64: str |
|
|
| |
| |
| |
|
|
| def verify_key(api_key: str): |
| if API_KEY and api_key != API_KEY: |
| raise HTTPException( |
| status_code=401, |
| detail="Invalid API Key" |
| ) |
|
|
|
|
| def resize_image(base64_str, max_size=1024): |
|
|
| if "," in base64_str: |
| base64_str = base64_str.split(",", 1)[1] |
|
|
| try: |
| img_data = base64.b64decode(base64_str) |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=f"base64 解码失败: {str(e)}") |
|
|
| try: |
| img = Image.open(BytesIO(img_data)) |
| img.verify() |
| img = Image.open(BytesIO(img_data)) |
| except Exception as e: |
| preview = img_data[:200] |
| raise HTTPException( |
| status_code=400, |
| detail=f"无法识别图片格式: {str(e)} | 数据前200字节: {preview}" |
| ) |
|
|
| if img.mode in ("RGBA", "P"): |
| img = img.convert("RGB") |
|
|
| img.thumbnail( |
| (max_size, max_size), |
| Image.Resampling.LANCZOS |
| ) |
|
|
| buffer = BytesIO() |
|
|
| img.save( |
| buffer, |
| format="JPEG", |
| quality=85 |
| ) |
|
|
| return base64.b64encode( |
| buffer.getvalue() |
| ).decode() |
|
|
|
|
| OPENROUTER_FALLBACK_MODELS = [ |
| "nvidia/nemotron-nano-12b-v2-vl:free", |
| "openrouter/free", |
| ] |
|
|
|
|
| def call_openrouter( |
| image_base64: str, |
| instruction: str |
| ): |
|
|
| if not OPENROUTER_KEY: |
| raise HTTPException( |
| status_code=500, |
| detail="OPENROUTER_API_KEY missing" |
| ) |
|
|
| url = "https://openrouter.ai/api/v1/chat/completions" |
| models = ["nex-agi/nex-n2-pro:free", *OPENROUTER_FALLBACK_MODELS] |
|
|
| headers = { |
| "Authorization": f"Bearer {OPENROUTER_KEY}", |
| "Content-Type": "application/json" |
| } |
|
|
| last_error = None |
|
|
| for model in models: |
| t0 = time.time() |
| log(f"call_openrouter trying model: {model}") |
| payload = { |
| "model": model, |
| "messages": [ |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "text", |
| "text": instruction |
| }, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": f"data:image/jpeg;base64,{image_base64}" |
| } |
| } |
| ] |
| } |
| ], |
| "temperature": 0.1 |
| } |
|
|
| try: |
| response = requests.post( |
| url, |
| headers=headers, |
| json=payload, |
| timeout=90 |
| ) |
|
|
| elapsed = time.time() - t0 |
| log(f"call_openrouter model={model} status={response.status_code} took={elapsed:.2f}s") |
|
|
| if response.status_code == 200: |
| return response.json() |
|
|
| last_error = ( |
| f"Model {model} failed: " |
| f"{response.status_code} {response.text[:500]}" |
| ) |
|
|
| except Exception as e: |
| elapsed = time.time() - t0 |
| log(f"call_openrouter model={model} error after {elapsed:.2f}s: {e}") |
| last_error = f"Model {model} error: {str(e)}" |
|
|
| raise HTTPException( |
| status_code=500, |
| detail=f"所有模型均失败: {last_error}" |
| ) |
|
|
| |
| |
| |
|
|
| @app.get("/") |
| def home(): |
| return { |
| "status": "running", |
| "service": "XiMa API" |
| } |
|
|
|
|
| @app.post("/analyze") |
| async def analyze( |
| request: ImageRequest, |
| x_api_key: str = Header(default="") |
| ): |
|
|
| verify_key(x_api_key) |
|
|
| try: |
|
|
| processed = resize_image( |
| request.image_base64 |
| ) |
|
|
| result = call_openrouter( |
| processed, |
| META_INSTRUCTION |
| ) |
|
|
| content = ( |
| result["choices"][0] |
| ["message"] |
| ["content"] |
| ) |
|
|
| return { |
| "success": True, |
| "analysis": content |
| } |
|
|
| except Exception as e: |
|
|
| raise HTTPException( |
| status_code=500, |
| detail=str(e) |
| ) |
|
|
|
|
| @app.post("/prompt") |
| async def prompt( |
| request: ImageRequest, |
| x_api_key: str = Header(default="") |
| ): |
|
|
| verify_key(x_api_key) |
|
|
| try: |
|
|
| processed = resize_image( |
| request.image_base64 |
| ) |
|
|
| result = call_openrouter( |
| processed, |
| PROMPT_INSTRUCTION |
| ) |
|
|
| prompt_text = ( |
| result["choices"][0] |
| ["message"] |
| ["content"] |
| ) |
|
|
| return { |
| "success": True, |
| "prompt": prompt_text |
| } |
|
|
| except Exception as e: |
|
|
| raise HTTPException( |
| status_code=500, |
| detail=str(e) |
| ) |
|
|
|
|
| @app.post("/generate") |
| async def generate( |
| request: ImageRequest, |
| x_api_key: str = Header(default="") |
| ): |
|
|
| verify_key(x_api_key) |
|
|
| if not MODAL_API_URL: |
| raise HTTPException( |
| status_code=500, |
| detail="MODAL_API_URL not configured" |
| ) |
|
|
| task_id = str(uuid.uuid4()) |
| tasks[task_id] = {"status": "pending"} |
|
|
| thread = threading.Thread( |
| target=background_generate, |
| args=(task_id, request.image_base64) |
| ) |
| thread.start() |
|
|
| return {"success": True, "task_id": task_id} |
|
|
|
|
| @app.get("/task/{task_id}") |
| async def get_task(task_id: str): |
| task = tasks.get(task_id) |
| if not task: |
| raise HTTPException(status_code=404, detail="Task not found") |
| return task |
|
|
|
|
| @app.get("/image/{task_id}") |
| async def get_image(task_id: str): |
| entry = image_store.get(task_id) |
| if not entry: |
| raise HTTPException(status_code=404, detail="Image not found") |
| image_bytes, content_type = entry |
| return Response(content=image_bytes, media_type=content_type) |
|
|
|
|
| if __name__ == "__main__": |
|
|
| import uvicorn |
|
|
| uvicorn.run( |
| app, |
| host="0.0.0.0", |
| port=7860 |
| ) |