baodan/api/insurance/poster/image_generator.py

268 lines
10 KiB
Python
Raw Normal View History

"""海报图片生成器 — 支持多供应商生图模型OpenAI / 豆包 / 智谱等)。"""
2026-07-23 15:04:16 +08:00
import os
import base64
2026-07-29 12:19:26 +08:00
import io
2026-07-23 15:04:16 +08:00
import logging
import tempfile
import httpx
2026-07-23 15:04:16 +08:00
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 {}
2026-07-23 15:04:16 +08:00
class PosterImageGenerator:
"""海报图片生成器,支持 OpenAI 兼容接口的多供应商生图模型。"""
2026-07-23 15:04:16 +08:00
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
2026-07-23 15:04:16 +08:00
def _get_client(self):
if self._client is None:
2026-07-23 15:04:16 +08:00
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")
# 安全校验拒绝私网地址SEC-P1-02
from insurance.utils.security import is_safe_base_url
is_safe, err_msg = is_safe_base_url(base_url)
if not is_safe:
raise ValueError(f"不安全的 Base URL: {err_msg}")
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"
2026-07-23 15:04:16 +08:00
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 _extract_image_bytes(self, response) -> bytes:
"""从图片 API 响应中提取图片 bytes支持 b64_json 和 URL 两种格式。"""
data_item = response.data[0] if response.data else None
if not data_item:
raise ValueError("图片 API 返回空数据")
# 优先使用 b64_json
if hasattr(data_item, 'b64_json') and data_item.b64_json:
return base64.b64decode(data_item.b64_json)
# 回退到 URL 下载
if hasattr(data_item, 'url') and data_item.url:
logger.info(f"图片 API 返回 URL正在下载: {data_item.url[:80]}...")
resp = httpx.get(data_item.url, timeout=60, follow_redirects=True)
resp.raise_for_status()
return resp.content
raise ValueError("图片 API 响应既无 b64_json 也无 url")
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> tuple:
"""调用配置的生图模型生成海报图片。
2026-07-23 15:04:16 +08:00
返回:
(image_bytes, provider_info) PNG 图片的 bytes 和生成信息
2026-07-23 15:04:16 +08:00
"""
api_size = SIZE_MAP.get(size, "1024x1792")
client = self._get_client()
model = self._get_model()
provider_info = {"provider": self._get_config().get("poster_image_provider", "openai"), "model": model}
2026-07-23 15:04:16 +08:00
kwargs = {
"model": model,
2026-07-23 15:04:16 +08:00
"prompt": prompt,
"n": 1,
"size": api_size,
}
2026-07-29 12:19:26 +08:00
# 后台模板可配置本地参考图或公网 URL失败时降级为纯 prompt 生图。
if reference_image:
image_file = None
close_file = False
try:
if reference_image.startswith(("http://", "https://")):
from insurance.utils.security import is_safe_base_url
is_safe, error_message = is_safe_base_url(reference_image)
if not is_safe:
raise ValueError(error_message)
image_response = httpx.get(
reference_image, timeout=30, follow_redirects=False
)
image_response.raise_for_status()
if len(image_response.content) > 10 * 1024 * 1024:
raise ValueError("参考图超过 10MB")
image_file = io.BytesIO(image_response.content)
image_file.name = "reference.png"
elif os.path.exists(reference_image):
from insurance.config import get_storage_root
ref_abs = os.path.abspath(reference_image)
storage_root = os.path.abspath(get_storage_root())
if os.path.commonpath([ref_abs, storage_root]) != storage_root:
raise ValueError("参考图不在允许的存储目录")
image_file = open(ref_abs, "rb")
close_file = True
if image_file is not None:
response = client.images.edit(
model=model,
image=image_file,
prompt=prompt,
n=1,
size=api_size,
)
return self._extract_image_bytes(response), provider_info
2026-07-29 12:19:26 +08:00
except Exception as e:
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
finally:
if close_file and image_file is not None:
image_file.close()
2026-07-23 15:04:16 +08:00
response = client.images.generate(**kwargs)
return self._extract_image_bytes(response), provider_info
2026-07-23 15:04:16 +08:00
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()