POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底

- 缓存热点批量流程(有采集缓存不触发 Google)
- 简报不足直接从采集缓存生成(轻量补齐)
- 三图合成(模特/印花/底图)+ 底图压缩 <2MB
- 热点去重→风格去重自动切换 + 不适合类目 review 兜底
- 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述)
- 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
2026-08-22 14:14:01 +08:00
commit f493bde8a9
98 changed files with 10280 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
"""OpenAI 图像后端(生成设计稿 + 三图合成)。
支持 OpenAI images/generations(文生图)与 images/editsimg2img 多参考图),
兼容两类网关返回:
1) 同步:{"data": [{"b64_json" | "url"}]}
2) 异步任务(ai-media.vip 等重负载自动转异步):
提交返回 202 + {"object":"image.task","task_id","poll_url","poll_after_ms"}
需轮询 GET {base}/images/tasks/{task_id} 直到 succeeded,再从成功响应取图。
所有请求跳过环境代理(NO_PROXY),适配用户挂 VPN 时直连国内/自建网关。
"""
import base64
import time
from pathlib import Path
from typing import Optional
import requests
import os
# 模型调用一律直连:用户常开 VPN(系统代理),网关多为国内/自建,走代理会被拦截或变慢。
# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。
os.environ.setdefault('NO_PROXY', '*')
os.environ.setdefault('no_proxy', '*')
from .base import ImageBackend
# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求)
NO_PROXY = {"http": None, "https": None}
_FINAL_STATUS = ("succeeded", "completed", "done")
_FAIL_STATUS = ("failed", "error")
def _shrink_blob_to_2mb(img_path: str, blob: bytes, max_bytes: int = 2 * 1024 * 1024) -> bytes:
"""把大图压缩到 <max_bytes(默认 2MB)再上传:
- 尺寸过大先缩放(合成输入 1600x2200 内足够);
- 有透明通道 → PNG 优化;否则 → JPEG 循环降质。
失败时返回原 blob(不阻塞流程)。"""
try:
import io
from PIL import Image
img = Image.open(io.BytesIO(blob))
max_w, max_h = 1600, 2200
if img.width > max_w or img.height > max_h:
img.thumbnail((max_w, max_h))
has_alpha = img.mode in ("RGBA", "LA")
if not has_alpha:
img = img.convert("RGB")
for q in (88, 70, 50, 35):
buf = io.BytesIO()
if has_alpha:
img.save(buf, format="PNG", optimize=True)
else:
img.save(buf, format="JPEG", quality=q)
if buf.tell() <= max_bytes:
return buf.getvalue()
# 保底:最小质量 PNG/JPEG
buf = io.BytesIO()
if has_alpha:
img.save(buf, format="PNG", optimize=True)
else:
img.save(buf, format="JPEG", quality=30)
return buf.getvalue()
except Exception as e: # noqa: BLE001
print(f"[img] 图片压缩失败(用原图): {e}")
return blob
def _save_from_response(j: dict, out_path: str) -> str:
"""从同步/异步最终响应提取图片(data[].b64_json 或 url)并保存。"""
data_item = (j.get("data") or [{}])[0]
b64 = data_item.get("b64_json")
if b64:
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(base64.b64decode(b64))
return out_path
url = data_item.get("url")
if not url:
raise RuntimeError(f"图像 API 返回无 b64_json/url: {str(j)[:200]}")
img_resp = requests.get(url, proxies=NO_PROXY, timeout=120)
img_resp.raise_for_status()
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(img_resp.content)
return out_path
def _wait_task(base_url: str, headers: dict, task_id: str, poll_after_ms: int,
timeout: int = 60) -> dict:
"""轮询异步图像任务直到完成;uncertain(结果暂时不确定)继续等,不重复提交。
timeout 默认 60s:异步路径不稳定(ai-media 网关常 uncertain/task not found),
超时即抛异常由调用方重试同步提交。"""
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
time.sleep(max(int(poll_after_ms or 2000), 2000) / 1000.0)
try:
resp = requests.get(f"{base_url}/images/tasks/{task_id}", headers=headers,
timeout=60, proxies=NO_PROXY)
if resp.status_code >= 400:
continue
j = resp.json()
except Exception as e: # noqa: BLE001
print(f"[img] 任务轮询异常(重试): {e}")
continue
st = j.get("status")
if st != last:
print(f"[img] 异步任务 {task_id[:20]}… status={st}")
last = st
if st in _FINAL_STATUS:
return j
if st in _FAIL_STATUS:
raise RuntimeError(f"图像异步任务失败: {j.get('error')} {j.get('error_code')}")
# queued / running / uncertain → 继续等
raise RuntimeError(f"图像异步任务超时({timeout}s")
def _resolve_task_or_sync(j: dict, base_url: str, headers: dict, out_path: str) -> str:
"""提交后的统一处理:异步任务则轮询,然后提取图片保存。"""
if j.get("object") == "image.task" or j.get("task_id") or j.get("id", "").startswith("imgtask"):
tid = j.get("task_id") or j.get("id") or ""
if not tid:
raise RuntimeError(f"异步任务无 task_id: {str(j)[:200]}")
j = _wait_task(base_url, headers, tid, int(j.get("poll_after_ms") or 2000))
return _save_from_response(j, out_path)
class OpenAIImageBackend(ImageBackend):
name = "openai"
def __init__(self):
self._cfg: dict = {}
def bind_config(self, cfg: dict):
self._cfg = cfg or {}
def _base_url(self) -> str:
return str(self._cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/")
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
extra_images=None, size: str = "") -> str:
"""img2img 编辑:参考图 base_image+ 可选 extra_images 多参考图)按 prompt 生成新图。
- 平铺服装图:base_image=平铺衣服底图(图3)extra_images=[印花设计稿(图2)]
- 三图模特合成:base_image=模特图(图1)extra_images=[印花设计稿(图2), 平铺底图(图3)]
提交顺序即图1→图2→图3,与提示词中的图片角色一一对应。
size: 显式尺寸覆盖(如 "1504x2000");留空用配置 size(默认 1024x1024)。
"""
cfg = self._cfg
api_key = cfg.get("api_key", "")
model = cfg.get("model", "gpt-image-1")
if not api_key:
raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。")
full_prompt = prompt + (f"\nNegative: {negative}" if negative else "")
base_url = self._base_url()
headers = {"Authorization": f"Bearer {api_key}"}
# 必须把文件读成 bytes 再提交(句柄随 with 关闭会导致 "read of closed file");
# 输入图(模特/设计/底图)先压缩校验:>2MB 压缩到 <2MB 再上传,避免大图拖垮网关导致超时
file_payloads = []
for img_path in [base_image] + list(extra_images or []):
blob = Path(img_path).read_bytes()
if len(blob) > 2 * 1024 * 1024:
shrunk = _shrink_blob_to_2mb(img_path, blob)
print(f"[img] 输入图压缩: {Path(img_path).name} {len(blob)//1024}KB → {len(shrunk)//1024}KB<2MB")
blob = shrunk
file_payloads.append((Path(img_path).name, blob))
files = [("image", (name, blob, "image/png")) for name, blob in file_payloads]
data = {
"prompt": full_prompt,
"n": 1,
"size": size or cfg.get("size", "1024x1024"),
"model": model,
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
}
# 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次);
# 内容政策拦截(content_policy_violation)多为网关误判 → 等待后重试
last_err: Optional[str] = None
for attempt in range(3):
resp = requests.post(f"{base_url}/images/edits", headers=headers, files=files,
data=data, timeout=300, proxies=NO_PROXY)
if resp.status_code >= 400:
body = resp.text or ""
if "content_policy" in body and attempt < 2:
print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3")
time.sleep(2)
continue
if attempt == 0:
data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试
continue
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
try:
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
except Exception as e: # noqa: BLE001
last_err = str(e)
print(f"[img] 第 {attempt + 1} 次提交异步失败,重试同步提交: {e}")
raise RuntimeError(f"图像合成多次提交均失败: {last_err}")
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
"""纯文生图:生成白底纯印花设计稿(standalone pure print design)。
size: 显式尺寸覆盖(印花设计统一 1024x1024);留空用配置 size。
background: 配置 compose.background="transparent" 时传 background 参数 → 透明背景 PNG
gpt-image-1/2 等模型支持;网关不支持该参数时会被忽略或由网关兜底)。"""
cfg = self._cfg
api_key = cfg.get("api_key", "")
model = cfg.get("model", "gpt-image-1")
if not api_key:
raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。")
full_prompt = prompt + (f"\nNegative: {negative}" if negative else "")
base_url = self._base_url()
headers = {"Authorization": f"Bearer {api_key}"}
data = {
"prompt": full_prompt,
"n": 1,
"size": size or cfg.get("size", "1024x1024"),
"model": model,
"response_format": "b64_json",
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
}
bg = str(cfg.get("background") or "").strip()
if bg:
data["background"] = bg # 如 "transparent"(透明背景 PNG
last_err: Optional[str] = None
for attempt in range(3):
resp = requests.post(f"{base_url}/images/generations", headers=headers, json=data,
timeout=300, proxies=NO_PROXY)
if resp.status_code >= 400:
body = resp.text or ""
if "content_policy" in body and attempt < 2:
print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3")
time.sleep(2)
continue
if attempt == 0:
data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试
continue
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
try:
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
except Exception as e: # noqa: BLE001
last_err = str(e)
print(f"[img] 第 {attempt + 1} 次生成异步失败,重试同步提交: {e}")
raise RuntimeError(f"图像生成多次提交均失败: {last_err}")