baodan/api/insurance/poster/image_generator.py
wsb1224 02e299682e feat: support configurable image generation models for poster creation
- Expand poster image model settings with provider, model name, API key,
  and base URL fields (was only model name + API key)
- Add presets for OpenAI (gpt-image-1), Doubao (火山引擎), and Zhipu (CogView)
- Refactor image_generator.py to read config from system_settings DB instead
  of hardcoding gpt-image-1 and OPENAI_API_KEY env var
- All OpenAI-compatible image APIs work via configurable base URL
- Add doubao and cogview to brand name mapping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:11:37 +08:00

217 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""海报图片生成器 — 支持多供应商生图模型OpenAI / 豆包 / 智谱等)。"""
import os
import base64
import logging
logger = logging.getLogger(__name__)
# 用户尺寸 → API 尺寸映射
SIZE_MAP = {
"1080x1920": "1024x1792",
"900x500": "1792x1024",
"1080x1080": "1024x1024",
"800x1200": "1024x1792",
}
def _load_image_config() -> dict:
"""从数据库读取海报图片模型配置。"""
try:
from insurance.db.compat import db
from insurance.models.system_setting import SystemSetting
keys = ["poster_image_provider", "poster_image_model",
"poster_image_api_key", "poster_image_base_url"]
settings = {s.key: s.value for s in SystemSetting.query.filter(SystemSetting.key.in_(keys)).all()}
return settings
except Exception:
return {}
class PosterImageGenerator:
"""海报图片生成器,支持 OpenAI 兼容接口的多供应商生图模型。"""
def __init__(self):
self._client = None
self._config = None
def _get_config(self) -> dict:
if self._config is None:
self._config = _load_image_config()
return self._config
def _get_client(self):
if self._client is None:
import openai
cfg = self._get_config()
api_key = cfg.get("poster_image_api_key") or os.getenv("OPENAI_API_KEY", "")
base_url = cfg.get("poster_image_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
self._client = openai.OpenAI(api_key=api_key, base_url=base_url)
return self._client
def _get_model(self) -> str:
cfg = self._get_config()
return cfg.get("poster_image_model") or "gpt-image-1"
def build_prompt(self, template: dict = None, product: dict = None,
company: dict = None, copy: dict = None, size: str = "1024x1792") -> str:
"""组装完整的生图 prompt。"""
parts = [
"你是一位专业的保险营销海报设计师。请根据以下信息生成一张保险营销海报。",
"",
]
# 风格要求
if template:
parts.append("【风格要求】")
parts.append(template.get("styleDescription", ""))
if template.get("colorScheme"):
parts.append(f"配色方案:{template['colorScheme']}")
parts.append("")
# 产品信息
if product:
parts.append("【产品信息】")
parts.append(f"产品名称:{product.get('displayName', '')}")
parts.append(f"所属公司:{company.get('displayName', '') if company else ''}")
if product.get("manualParsedRules"):
rules = product["manualParsedRules"]
if isinstance(rules, str):
import json
try:
rules = json.loads(rules)
except Exception:
rules = {}
features = rules.get("features", [])
if features:
parts.append("产品亮点:" + "".join(f.get("title", "") for f in features[:3]))
parts.append("")
# 营销文案
if copy:
parts.append("【营销文案】")
if copy.get("headline"):
parts.append(f"标题:{copy['headline']}")
if copy.get("body"):
parts.append(f"正文:{copy['body']}")
if copy.get("call_to_action"):
parts.append(f"行动号召:{copy['call_to_action']}")
parts.append("")
# 输出要求
parts.append("【输出要求】")
parts.append(f"- 尺寸:{size} 像素")
parts.append("- 文字清晰可读,中文为主")
parts.append("- 符合保险行业专业风格")
parts.append("- 包含公司 Logo 位置(如有)")
return "\n".join(parts)
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> bytes:
"""调用配置的生图模型生成海报图片。
返回:
PNG 图片的 bytes
"""
api_size = SIZE_MAP.get(size, "1024x1792")
client = self._get_client()
model = self._get_model()
kwargs = {
"model": model,
"prompt": prompt,
"n": 1,
"size": api_size,
}
# 如果有参考图,尝试传入(仅允许 uploads 目录下的文件)
uploads_root = os.path.abspath("uploads")
if reference_image and os.path.exists(reference_image):
ref_abs = os.path.abspath(reference_image)
if not ref_abs.startswith(uploads_root):
logger.warning(f"参考图路径不在允许目录: {reference_image}")
reference_image = None
try:
with open(reference_image, "rb") as img_file:
response = client.images.edit(
model=model,
image=img_file,
prompt=prompt,
n=1,
size=api_size,
)
image_base64 = response.data[0].b64_json
return base64.b64decode(image_base64)
except Exception as e:
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
response = client.images.generate(**kwargs)
image_base64 = response.data[0].b64_json
return base64.b64decode(image_base64)
def generate_fallback(copy_content: dict, size: str = "1024x1792") -> bytes:
"""降级方案:使用 Pillow 生成基础排版图。"""
from PIL import Image, ImageDraw, ImageFont
w, h = 1024, 1792
if "x" in size:
try:
parts = size.split("x")
w, h = int(parts[0]), int(parts[1])
except Exception:
pass
# 缩放到 API 支持的尺寸
w, h = min(w, 1792), min(h, 1792)
img = Image.new("RGB", (w, h), color=(26, 26, 46))
draw = ImageDraw.Draw(img)
# 简单排版
headline = copy_content.get("headline", "")
body = copy_content.get("body", "")
cta = copy_content.get("call_to_action", "")
# 尝试加载支持中文的字体
chinese_fonts = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc",
"C:/Windows/Fonts/msyh.ttc", # Windows 微软雅黑
"C:/Windows/Fonts/simsun.ttc", # Windows 宋体
]
font_large = font_medium = font_small = None
for font_path in chinese_fonts:
if os.path.exists(font_path):
try:
font_large = ImageFont.truetype(font_path, 48)
font_medium = ImageFont.truetype(font_path, 28)
font_small = ImageFont.truetype(font_path, 22)
break
except Exception:
continue
if font_large is None:
font_large = ImageFont.load_default()
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 绘制文字
y = h // 4
if headline:
draw.text((w // 2, y), headline, fill="white", font=font_large, anchor="mm")
y += 80
if body:
# 简单换行
for line in body.split("\n")[:4]:
draw.text((w // 2, y), line, fill=(200, 200, 200), font=font_medium, anchor="mm")
y += 40
if cta:
draw.text((w // 2, h - 200), cta, fill=(102, 126, 234), font=font_small, anchor="mm")
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()