POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""图像后端注册表(可插拔:印到底图 / 模特试穿 / 占位)。
|
||||
|
||||
compose_node / product_node 在配置 backend 时调用。默认未配置则跳过,仅导出提示词。
|
||||
新增图像后端:实现 graph/backends/base.ImageBackend,在此登记。
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from .base import ImageBackend
|
||||
from .openai_image_backend import OpenAIImageBackend
|
||||
from .mock_image_backend import MockImageBackend
|
||||
|
||||
IMAGE_BACKENDS: Dict[str, type] = {
|
||||
"openai": OpenAIImageBackend, # 真生图(images/edits img2img,需 api_key)
|
||||
"mock": MockImageBackend, # 占位图(Pillow,无 key 也能端到端演示)
|
||||
}
|
||||
|
||||
|
||||
def get_image_backend(name: str):
|
||||
cls = IMAGE_BACKENDS.get(name)
|
||||
if cls is None:
|
||||
return None
|
||||
return cls()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""图像后端抽象接口(可插拔)。
|
||||
|
||||
- print(prompt, base_image, out_path, negative, extra_images):
|
||||
以 base_image 为底图(+ extra_images 多参考图,按顺序追加)按 prompt 生成成品图。
|
||||
product 流水线用法:
|
||||
* 纯印花设计稿 → generate()
|
||||
* 平铺服装图(底图+印花)→ print(base=底图, extra=[设计稿])
|
||||
* 三图模特合成 → print(base=模特图, extra=[设计稿, 底图])(图1=模特, 图2=印花, 图3=底图)
|
||||
- generate(prompt, out_path, negative):纯文生图(无参考图),用于生成白底纯印花设计稿。
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Sequence
|
||||
|
||||
|
||||
class ImageBackend(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images: Optional[Sequence[str]] = None, size: str = "") -> str:
|
||||
"""返回生成的成品图路径。实现内部应处理调用失败/超时并抛异常由调用方兜底。
|
||||
size: 显式尺寸覆盖(如 "1504x2000");留空则用后端配置的 size。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
"""纯文生图(无参考图),默认退化为 print 不支持则抛异常。
|
||||
size: 显式尺寸覆盖;留空则用后端配置的 size。"""
|
||||
raise NotImplementedError(f"{self.name} 后端不支持纯文生图(generate)")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Mock 图像后端:Pillow 生成占位图。
|
||||
|
||||
无 OpenAI key 时也能端到端演示产品流水线(选品 → 底图 → "印花图" → "模特合成" 产物齐全)。
|
||||
占位图 = 参考图尺寸 + 文字标注(提示词摘要),明确标识 [MOCK] 避免误用。
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .base import ImageBackend
|
||||
|
||||
|
||||
class MockImageBackend(ImageBackend):
|
||||
name = "mock"
|
||||
|
||||
def __init__(self):
|
||||
self._cfg: dict = {}
|
||||
|
||||
def bind_config(self, cfg: dict):
|
||||
self._cfg = cfg or {}
|
||||
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images=None, size: str = "") -> str:
|
||||
size_px = (1024, 1024)
|
||||
try:
|
||||
with Image.open(base_image) as im:
|
||||
size_px = im.size
|
||||
except Exception:
|
||||
pass
|
||||
if size:
|
||||
try:
|
||||
w, h = (int(x) for x in str(size).lower().split("x"))
|
||||
size_px = (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
img = Image.new("RGB", size_px, (240, 240, 248))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(180, 180, 200))
|
||||
d.text((24, 24), f"[MOCK] {Path(out_path).name}", fill=(50, 50, 80))
|
||||
d.text((24, 56), "(未配置 OpenAI key,占位图演示流程)", fill=(120, 120, 150))
|
||||
d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120))
|
||||
if extra_images:
|
||||
d.text((24, 136), f"参考图 {len(list(extra_images))} 张: " + ", ".join(Path(p).name[:24] for p in extra_images), fill=(90, 90, 120))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(out_path)
|
||||
return out_path
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
"""纯文生图(mock):白底 + 文字标注,模拟纯印花设计稿。"""
|
||||
size_px = (1024, 1024)
|
||||
if size:
|
||||
try:
|
||||
w, h = (int(x) for x in str(size).lower().split("x"))
|
||||
size_px = (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
img = Image.new("RGB", size_px, (252, 252, 252))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(200, 200, 210))
|
||||
d.text((24, 24), f"[MOCK DESIGN] {Path(out_path).name}", fill=(50, 50, 80))
|
||||
d.text((24, 56), "(纯印花设计稿占位,白底)", fill=(120, 120, 150))
|
||||
d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(out_path)
|
||||
return out_path
|
||||
@@ -0,0 +1,242 @@
|
||||
"""OpenAI 图像后端(生成设计稿 + 三图合成)。
|
||||
|
||||
支持 OpenAI images/generations(文生图)与 images/edits(img2img 多参考图),
|
||||
兼容两类网关返回:
|
||||
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}")
|
||||
Reference in New Issue
Block a user