174 lines
7.2 KiB
Python
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、人物、头像、"
|
|
"基金图表、表格、产品名称、收益率、金融承诺、印章或水印。"
|
|
)
|