feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)

This commit is contained in:
HHY
2026-09-25 04:15:45 -07:00
parent 710b3521a2
commit 6726a9fa3a
12 changed files with 496 additions and 48 deletions
+107
View File
@@ -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
View File
@@ -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)
+9 -1
View File
@@ -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()
+169
View File
@@ -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"