feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""AI 炫图路由:gpt-image 生图代理(Key 仅存后端),提示词由 drawing_data 结构化构建。
|
||||
|
||||
- quick 模式: /v1/images/generations 纯提示词生成
|
||||
- photo 模式: /v1/images/edits 以用户照片为参考图,把窗型合成进照片
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from huamenchuang.core.config import settings
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.services.ai_prompt import build_prompt, describe_window, pick_size
|
||||
|
||||
router = APIRouter(prefix="/ai-render", tags=["AI 炫图"])
|
||||
|
||||
|
||||
class AiRenderBody(BaseModel):
|
||||
template_id: int | None = None
|
||||
drawing_data: str | dict | None = None
|
||||
mode: str = Field(default="quick", pattern="^(quick|photo)$")
|
||||
style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$")
|
||||
time: str = Field(default="day", pattern="^(day|dusk|night)$")
|
||||
# quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank
|
||||
scene: str = "indoor"
|
||||
photo_b64: str | None = Field(default=None, description="照片模式参考图(base64, 不带 dataURL 前缀)")
|
||||
|
||||
|
||||
async def _resolve_drawing(db, body: AiRenderBody) -> str | dict | None:
|
||||
if body.drawing_data:
|
||||
return body.drawing_data
|
||||
if body.template_id:
|
||||
from sqlalchemy import select
|
||||
from huamenchuang.models.windoor_template import WindoorTemplate
|
||||
t = (await db.execute(select(WindoorTemplate).where(
|
||||
WindoorTemplate.id == body.template_id))).scalar_one_or_none()
|
||||
return t.drawing_data if t else None
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/prompt")
|
||||
async def preview_prompt(body: AiRenderBody, user: CurrentUser, db: DbSession):
|
||||
"""提示词预览(前端可展开查看优化后的完整提示词)。"""
|
||||
drawing = await _resolve_drawing(db, body)
|
||||
if drawing is None:
|
||||
return fail("未找到图纸数据")
|
||||
try:
|
||||
info = describe_window(drawing)
|
||||
prompt = build_prompt(drawing, body.style, body.time, body.scene)
|
||||
except Exception:
|
||||
return fail("图纸数据解析失败")
|
||||
return ok(data={"features": info, "prompt": prompt,
|
||||
"size": pick_size(info["width_mm"], info["height_mm"])})
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
|
||||
"""云端 AI 生图(gpt-image):quick=生成 / photo=参考图编辑。"""
|
||||
if not settings.ai_api_key:
|
||||
return fail("AI 生图服务未配置(HMC_AI_API_KEY)")
|
||||
drawing = await _resolve_drawing(db, body)
|
||||
if drawing is None:
|
||||
return fail("未找到图纸数据")
|
||||
if body.mode == "photo" and not body.photo_b64:
|
||||
return fail("照片模式需要上传参考照片")
|
||||
try:
|
||||
info = describe_window(drawing)
|
||||
prompt = build_prompt(drawing, body.style, body.time, body.scene)
|
||||
size = pick_size(info["width_mm"], info["height_mm"])
|
||||
except Exception:
|
||||
return fail("图纸数据解析失败")
|
||||
|
||||
base = settings.ai_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {settings.ai_api_key}"}
|
||||
timeout = httpx.Timeout(settings.ai_timeout, connect=15)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
if body.mode == "photo":
|
||||
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/edits", headers=headers,
|
||||
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
|
||||
files={"image[]": ("photo.png", img_bytes, "image/png")})
|
||||
else:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations", headers=headers,
|
||||
json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
item = (data.get("data") or [{}])[0]
|
||||
b64 = item.get("b64_json")
|
||||
if not b64:
|
||||
return fail(f"生图服务返回异常: {str(data)[:200]}")
|
||||
except httpx.TimeoutException:
|
||||
return fail("AI 生图超时,请稍后重试")
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = e.response.text[:200] if e.response is not None else ""
|
||||
return fail(f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}")
|
||||
except Exception as e:
|
||||
return fail(f"生图失败: {str(e)[:150]}")
|
||||
return ok(data={"image_b64": b64, "prompt": prompt, "size": size,
|
||||
"features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"],
|
||||
"summary": info["summary"]}},
|
||||
message="生成成功")
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from huamenchuang.api import (
|
||||
ai_render,
|
||||
auth,
|
||||
business,
|
||||
contracts,
|
||||
@@ -29,6 +30,7 @@ api_router.include_router(quote.router)
|
||||
api_router.include_router(script.router)
|
||||
api_router.include_router(order_format.router)
|
||||
api_router.include_router(finance.router)
|
||||
api_router.include_router(ai_render.router)
|
||||
api_router.include_router(warehouse.router)
|
||||
api_router.include_router(business.router)
|
||||
api_router.include_router(platform.router)
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}"
|
||||
_ENV_FILE = _PROJECT_ROOT / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -19,8 +20,15 @@ class Settings(BaseSettings):
|
||||
"template", "customer", "contract", "warehouse", "order",
|
||||
"quote", "optimize", "render",
|
||||
]
|
||||
# AI 炫图(OpenAI Images 兼容中转站;未配置时前端降级本地程序化渲染)
|
||||
ai_base_url: str = "https://geminiapi.ai"
|
||||
ai_api_key: str = ""
|
||||
ai_model: str = "gpt-image-2.5-1k"
|
||||
ai_timeout: int = 180
|
||||
|
||||
model_config = {"env_prefix": "HMC_", "env_file": ".env"}
|
||||
# .env 锚定项目根(与进程 cwd 无关); 不存在时不加载
|
||||
model_config = {"env_prefix": "HMC_",
|
||||
"env_file": str(_ENV_FILE) if _ENV_FILE.exists() else None}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""AI 炫图提示词构建器:从 drawing_data 提取窗型结构特征,生成专业英文生图提示词。
|
||||
|
||||
设计原则(生图模型工程实践):
|
||||
- 主体先行: 窗户的几何/材质/分格描述置前,场景氛围居后
|
||||
- 结构量化: 宽高 mm、分格数、扇数、开向全部显式给出,抑制模型自由发挥
|
||||
- 负面约束: 明确"保持窗型几何不变"(edits 模式关键)
|
||||
- 光影专项: 时间(日/黄昏/夜)与室内外视角分别给出摄影级光照词
|
||||
"""
|
||||
from huamenchuang.core.drawing import parse_drawing, replay_shape
|
||||
|
||||
# 风格 → 型材材质/颜色描述
|
||||
STYLE_FINISH = {
|
||||
"modern": ("matte anthracite grey aluminum frames, minimalist slim profiles", "modern minimalist"),
|
||||
"chinese": ("dark bronze aluminum frames with wood-grain inner finish and subtle Chinese lattice grille", "new-Chinese style"),
|
||||
"european": ("cream white aluminum frames with classic decorative architrave", "classic European"),
|
||||
"nordic": ("white aluminum frames with light oak interior foil", "Scandinavian Nordic"),
|
||||
}
|
||||
|
||||
# 时间 → 摄影光照
|
||||
TIME_LIGHT = {
|
||||
"day": "bright midday daylight, soft natural shadows, clear sky",
|
||||
"dusk": "warm golden-hour sunset light, long soft shadows, amber sky",
|
||||
"night": "night scene, warm interior glow shining through the glass, cool blue ambience",
|
||||
}
|
||||
|
||||
# 场景 → 场景描述(照片式 7 场景对齐源站 renderV2)
|
||||
SCENE_DESC = {
|
||||
"render": "the window installed on a clean rendered exterior wall, straight-on elevation view",
|
||||
"install": "the brand-new window just mounted into the wall opening, installation foam and fixing screws still visible at the edges, home renovation context",
|
||||
"erase": "renovation scene: the old worn window has been removed from the masonry opening and this new window is being fitted in its place, dusty sill",
|
||||
"indoor": "interior view, the window as the focal point of a tastefully furnished {style_word} living room with sofa, wooden floor, curtains and green plants",
|
||||
"villa": "exterior view of a {style_word} villa facade featuring this large window, landscaped garden with lawn below, blue sky",
|
||||
"fence": "view from inside looking out through the window, clear unobstructed garden and distant hills beyond the glass, no railings or bars outside",
|
||||
"blank": "unfurnished rough-condition room with bare grey cement walls, the window as the only fitted element, pre-renovation state",
|
||||
}
|
||||
|
||||
# 快速模式场景(无照片)
|
||||
QUICK_SCENE = {
|
||||
"indoor": "interior view in a tastefully furnished {style_word} room, the window as the light source",
|
||||
"outdoor": "exterior facade view on a residential building, mild urban greenery around",
|
||||
"studio": "studio product shot on a seamless neutral background, softbox lighting, slight floor reflection",
|
||||
}
|
||||
|
||||
|
||||
def _sash_kind(sub: dict) -> str | None:
|
||||
t = str(sub.get("type", ""))
|
||||
is_door = bool(sub.get("isDoor"))
|
||||
if "Screen" in t:
|
||||
return None # 纱扇不参与文字描述
|
||||
if "Kfc" in t:
|
||||
return "tilt-and-turn " + ("door leaf" if is_door else "sash")
|
||||
if "DoubleSash" in t:
|
||||
return "pair of French " + ("door leaves" if is_door else "casement sashes opening from the center")
|
||||
if "Slide" in t:
|
||||
return "sliding " + ("door panel" if is_door else "sash")
|
||||
if t in ("Sash",):
|
||||
return "side-hung casement " + ("door leaf" if is_door else "sash")
|
||||
return None
|
||||
|
||||
|
||||
def _opening_words(sub: dict) -> str:
|
||||
toward = str(sub.get("openToward") or "")
|
||||
od = sub.get("od")
|
||||
if "up" in str(sub.get("openDirection") or "").lower() or (isinstance(od, (int, float)) and 60 <= od <= 180 and "up" in str(sub.get("openDirection", "")).lower()):
|
||||
return "top-hung"
|
||||
return "inward-opening" if toward == "inward" else "outward-opening"
|
||||
|
||||
|
||||
def describe_window(drawing: str | dict) -> dict:
|
||||
"""从 drawing_data 提取窗型结构特征(供提示词与前端展示)。"""
|
||||
dd = parse_drawing(drawing)
|
||||
shapes = [s for s in dd.get("sm", []) if isinstance(s, dict)]
|
||||
if not shapes:
|
||||
return {"count": 0, "summary": "", "width_mm": 0, "height_mm": 0}
|
||||
|
||||
xs, ys = [], []
|
||||
parts: list[str] = []
|
||||
sash_total = 0
|
||||
has_arc = has_screen = has_deco = False
|
||||
for shape in shapes:
|
||||
regions = replay_shape(shape)
|
||||
pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions]
|
||||
if not pts:
|
||||
continue
|
||||
flat = [p for r in pts for p in r]
|
||||
x0, y0 = min(p[0] for p in flat), min(p[1] for p in flat)
|
||||
x1, y1 = max(p[0] for p in flat), max(p[1] for p in flat)
|
||||
xs += [x0, x1]; ys += [y0, y1]
|
||||
w, h = round(x1 - x0), round(y1 - y0)
|
||||
n_region = len(regions)
|
||||
# 扇描述
|
||||
sashes = [sub for sub in (shape.get("sm") or []) if isinstance(sub, dict)]
|
||||
kind_words: list[str] = []
|
||||
for sub in sashes:
|
||||
k = _sash_kind(sub)
|
||||
if k is None:
|
||||
if "Screen" in str(sub.get("type", "")):
|
||||
has_screen = True
|
||||
continue
|
||||
sash_total += 1
|
||||
kind_words.append(f"{_opening_words(sub)} {k}")
|
||||
if shape.get("rt"):
|
||||
has_arc = True
|
||||
if shape.get("decs"):
|
||||
has_deco = True
|
||||
part = f"{w}×{h}mm unit with {n_region} glass panes"
|
||||
if kind_words:
|
||||
part += ", " + " and ".join(dict.fromkeys(kind_words))
|
||||
parts.append(part)
|
||||
|
||||
if not xs:
|
||||
return {"count": 0, "summary": "", "width_mm": 0, "height_mm": 0}
|
||||
width_mm, height_mm = round(max(xs) - min(xs)), round(max(ys) - min(ys))
|
||||
multi = len(parts) > 1
|
||||
summary = "; ".join(parts)
|
||||
if multi:
|
||||
summary = f"a combination of {len(parts)} window units joined together: " + summary
|
||||
else:
|
||||
summary = parts[0]
|
||||
if has_arc:
|
||||
summary += ", arched/curved top (heritage style)"
|
||||
if has_screen:
|
||||
summary += ", with retractable fly screen"
|
||||
if has_deco:
|
||||
summary += ", with decorative gliding bars (grilles) between the glass"
|
||||
return {"count": len(parts), "summary": summary, "width_mm": width_mm,
|
||||
"height_mm": height_mm, "sashes": sash_total}
|
||||
|
||||
|
||||
def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day",
|
||||
scene: str = "render", ratio: str = "landscape") -> str:
|
||||
"""构建优化后的完整英文提示词。
|
||||
|
||||
ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。
|
||||
"""
|
||||
info = describe_window(drawing)
|
||||
finish, style_word = STYLE_FINISH.get(style, STYLE_FINISH["modern"])
|
||||
light = TIME_LIGHT.get(time, TIME_LIGHT["day"])
|
||||
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
|
||||
scene_text = scene_tpl.replace("{style_word}", style_word)
|
||||
|
||||
window_desc = info["summary"] or "a modern aluminum window"
|
||||
composition = {
|
||||
"portrait": "vertical composition, window fills most of the frame height",
|
||||
"landscape": "horizontal composition, generous wall context around the window",
|
||||
"square": "balanced square composition, window centered",
|
||||
}.get(ratio, "")
|
||||
|
||||
prompt = (
|
||||
f"Photorealistic architectural photograph of {scene_text}. "
|
||||
f"The window: {window_desc}; {finish}. "
|
||||
f"Lighting: {light}. {composition} "
|
||||
"Keep the window geometry, pane division and frame profiles exactly as described — do not add or remove sashes. "
|
||||
"Realistic glass with soft sky reflection, physically correct shadows and contact shadows, "
|
||||
"true-to-material aluminum texture, high dynamic range, ultra-detailed, 8k, professional architectural photography, no people, no text, no watermark."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def pick_size(width_mm: int, height_mm: int) -> str:
|
||||
"""按窗型宽高比选择生成尺寸(gpt-image 支持 1024x1024 / 1536x1024 / 1024x1536)。"""
|
||||
if not width_mm or not height_mm:
|
||||
return "1024x1024"
|
||||
r = width_mm / height_mm
|
||||
if r > 1.2:
|
||||
return "1536x1024"
|
||||
if r < 0.83:
|
||||
return "1024x1536"
|
||||
return "1024x1024"
|
||||
Reference in New Issue
Block a user