Files
group_fqcd_jr/app/service/promotion_image_service.py
T

174 lines
7.2 KiB
Python

"""阿里云百炼背景图生成服务。"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import httpx
from app.core.config import get_settings
from app.core.errors import DependencyUnavailableError, UpstreamTimeoutError
from app.service.model_gateway import EnvironmentSecretResolver
logger = logging.getLogger(__name__)
class PromotionImageService:
"""生成无文字底图,供渲染器美化整页与内容面板。"""
def __init__(self) -> None:
settings = get_settings()
self.enabled = settings.promotion_image_enabled
self.api_key = ""
if self.enabled:
try:
self.api_key = EnvironmentSecretResolver().resolve(
"env:DASHSCOPE_API_KEY"
)
except Exception:
logger.warning("DASHSCOPE_API_KEY 未配置,背景图生成已降级", exc_info=True)
self.enabled = False
self.base_url = settings.promotion_image_base_url.rstrip("/")
self.model = settings.promotion_image_model
self.timeout_seconds = settings.promotion_image_timeout_seconds
self.poll_interval_seconds = settings.promotion_image_poll_interval_seconds
async def generate_background(
self,
*,
output_path: str | Path,
style_code: str,
background_theme: str,
fund_type: str,
) -> str | None:
if not self.enabled or not self.api_key:
return None
prompt = self._build_prompt(
style_code=style_code,
background_theme=background_theme,
fund_type=fund_type,
)
try:
async with httpx.AsyncClient() as client:
task_id = await self._submit(client, prompt)
image_url = await self._poll(client, task_id)
response = await client.get(
image_url,
timeout=httpx.Timeout(self.timeout_seconds),
)
response.raise_for_status()
destination = Path(output_path)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(self._tone_down_background(response.content))
return str(destination)
except httpx.TimeoutException:
logger.warning("阿里云背景图生成超时,使用程序化背景", exc_info=True)
return None
except (
httpx.HTTPError,
DependencyUnavailableError,
UpstreamTimeoutError,
KeyError,
TypeError,
ValueError,
):
logger.warning("阿里云背景图生成失败,使用程序化背景", exc_info=True)
return None
async def _submit(self, client: httpx.AsyncClient, prompt: str) -> str:
response = await client.post(
f"{self.base_url}/api/v1/services/aigc/text2image/image-synthesis",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
json={
"model": self.model,
"input": {"prompt": prompt},
"parameters": {"size": "1024*1024", "n": 1},
},
timeout=httpx.Timeout(self.timeout_seconds),
)
response.raise_for_status()
body: Any = response.json()
task_id = body.get("output", {}).get("task_id") if isinstance(body, dict) else None
if not isinstance(task_id, str) or not task_id:
raise DependencyUnavailableError("阿里云背景图任务响应缺少 task_id")
return task_id
async def _poll(self, client: httpx.AsyncClient, task_id: str) -> str:
url = f"{self.base_url}/api/v1/tasks/{task_id}"
max_polls = max(1, int(self.timeout_seconds / self.poll_interval_seconds))
for _ in range(max_polls):
response = await client.get(
url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=httpx.Timeout(self.timeout_seconds),
)
response.raise_for_status()
body: Any = response.json()
output = body.get("output", {}) if isinstance(body, dict) else {}
status = output.get("task_status")
if status == "SUCCEEDED":
results = output.get("results") or []
image_url = results[0].get("url") if results else None
if isinstance(image_url, str) and image_url:
return image_url
raise DependencyUnavailableError("阿里云背景图任务缺少图片地址")
if status in {"FAILED", "CANCELED", "UNKNOWN"}:
raise DependencyUnavailableError("阿里云背景图任务未成功")
await self._sleep()
raise UpstreamTimeoutError("阿里云背景图任务轮询超时")
async def _sleep(self) -> None:
import asyncio
await asyncio.sleep(self.poll_interval_seconds)
@staticmethod
def _tone_down_background(payload: bytes) -> bytes:
"""将模型背景处理成低干扰底图,避免纹理压过材料正文。"""
import io
from PIL import Image, ImageEnhance, ImageFilter
with Image.open(io.BytesIO(payload)) as source:
image = source.convert("RGB")
image = ImageEnhance.Color(image).enhance(0.22)
image = ImageEnhance.Contrast(image).enhance(0.30)
image = ImageEnhance.Brightness(image).enhance(1.22)
image = image.filter(ImageFilter.GaussianBlur(radius=1.0))
output = io.BytesIO()
image.save(output, format="PNG", optimize=True)
return output.getvalue()
@staticmethod
def _build_prompt(
*,
style_code: str,
background_theme: str,
fund_type: str,
) -> str:
style = {
"steady_professional": "稳健、克制、专业的暖色金融视觉",
"growth_research": "理性、现代、研究感的蓝色金融视觉",
"balanced_allocation": "平衡、清晰、专业的蓝金金融视觉",
}.get(style_code, "专业、克制、清晰的金融视觉")
theme = {
"data_lines": "抽象数据线、轻量节点和流动曲线",
"geometric_grid": "低对比几何网格、细线和少量节点",
"soft_wave": "柔和抽象波形和层次光影",
}.get(background_theme, "低对比抽象几何纹理")
return (
"Use case: productivity-visual. "
"为基金产品推介材料生成可作为 PPT 和宣传长图底图的抽象背景。"
f"基金类型语境:{fund_type}。视觉风格:{style}。主要元素:{theme}。"
"宽松留白,主体内容区域保持低对比,适合叠加中文文字和数据图表;"
"画面主色应可用于提取同色系的半透明文字板块背景。"
"只生成背景和装饰纹理,不生成任何文字、数字、字母、Logo、人物、头像、"
"基金图表、表格、产品名称、收益率、金融承诺、印章或水印。"
)