feat: 增加水印

This commit is contained in:
R524809
2026-08-24 17:15:08 +08:00
parent 95cb93a160
commit 024777ed8a
13 changed files with 272 additions and 7 deletions
+1
View File
@@ -103,6 +103,7 @@ async def generate_suite(
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
watermark=req.watermark.model_dump() if req.watermark else None,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+6
View File
@@ -9,6 +9,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# 仓库根(server/ 的上一级)
ROOT = Path(__file__).resolve().parents[1]
# 水印徽章图(复刻 ozonSeller 图表处理的默认水印;请求可选图片/文字水印)
WATERMARK_IMAGE_PATH = Path(__file__).resolve().parent / "assets" / "watermark.jpg"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -26,6 +29,9 @@ class Settings(BaseSettings):
# ── 存储 ──
data_dir: str = str(ROOT / "data")
# 水印图片路径(图片水印的徽章源图,可用 .env 覆盖)
watermark_image_path: str = str(WATERMARK_IMAGE_PATH)
# ── 图像生成 providerdoubao(火山方舟 Seedream| tongyi(阿里 DashScope)──
image_provider: str = "doubao"
request_timeout: int = 300 # 单张生图请求超时(秒)
+1
View File
@@ -4,3 +4,4 @@ pydantic>=2.6
pydantic-settings>=2.2
httpx>=0.27
python-multipart>=0.0.9
pillow>=10.0
+14
View File
@@ -1,6 +1,8 @@
"""Pydantic 契约(插件 ↔ 服务端)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
SUPPORTED_TYPES = [
@@ -62,6 +64,17 @@ class GenerateImageItem(BaseModel):
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
class WatermarkOptions(BaseModel):
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。
默认样式复刻 ozonSeller 图表处理:图片徽章 / 文字描边,右下角。
"""
enabled: bool = Field(default=False, description="是否开启水印")
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
text: str = Field(default="xiongmaoyx", description="文字水印内容")
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
@@ -83,6 +96,7 @@ class GenerateRequest(BaseModel):
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
provider: str | None = Field(default=None, description="覆盖默认 providerdoubao | tongyi")
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成)")
# ── 出图方案规划(DeepSeek)──
+5
View File
@@ -18,6 +18,7 @@ from config import get_settings
from services import storage
from services.prompts import build_prompt, build_context, type_name
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_FAILED, IMG_OK
from services.watermark import apply_watermark
log = logging.getLogger("suite.generator")
@@ -435,6 +436,10 @@ async def run_suite(task: Task) -> None:
)
refs = _refs_for_job(list(task.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model)
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
wm = task.watermark or {}
if wm.get("enabled"):
data = apply_watermark(data, wm)
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
+1
View File
@@ -53,6 +53,7 @@ class Task:
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
watermark: dict | None = None # 水印选项(落盘前服务端后处理)
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
+122
View File
@@ -0,0 +1,122 @@
"""生成图水印:AI 出图返回后、落盘前的后处理合成(不经过生图模型)。
样式复刻 ozonSeller「图表处理」的默认水印:
- 图片水印:徽章图中心裁方 → 圆形遮罩 → 宽度为图宽 15%,右下角,边距约 1% 图宽;
- 文字水印:字号为图宽 6%(下限 12px),白色填充 + 黑色描边(alpha 0.55
描边宽 fontSize/8),加粗无衬线。
容错原则:字体/资产缺失或合成异常时 log 警告并返回原图,绝不阻断生图。
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from config import get_settings
log = logging.getLogger("suite.watermark")
# 尺寸比例(与 ozonSeller app.js 常量一致)
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
TEXT_SCALE = 0.06 # 文字字号 / 图宽
TEXT_MIN_SIZE = 12
STROKE_ALPHA = 0.55
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
# CJK/西文字体回退链(macOS 本地服务);命中后模块级缓存
_FONT_CANDIDATES = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
_font_path: str | None = None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
global _font_path
if _font_path is None:
_font_path = next((p for p in _FONT_CANDIDATES if Path(p).is_file()), "")
if _font_path:
return ImageFont.truetype(_font_path, size)
log.warning("未找到系统字体(%s),文字水印退化为 Pillow 默认字体,中文可能乱码", _FONT_CANDIDATES)
return ImageFont.load_default(size) if size >= 10 else ImageFont.load_default()
def _circular_badge(size: int) -> Image.Image | None:
"""徽章资产 → 指定直径的圆形 RGBA 贴片;资产缺失返回 None。"""
path = get_settings().watermark_image_path
try:
badge = Image.open(path).convert("RGBA")
except Exception as exc: # noqa: BLE001
log.warning("水印图片加载失败(%s),跳过图片水印: %s", path, exc)
return None
side = min(badge.size) # 中心裁方
left, top = (badge.width - side) // 2, (badge.height - side) // 2
square = badge.crop((left, top, left + side, top + side)).resize((size, size))
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
square.putalpha(mask)
return square
def _apply_image_watermark(canvas: Image.Image, opacity: float) -> None:
size = max(24, round(canvas.width * BADGE_SCALE))
badge = _circular_badge(size)
if badge is None:
return
badge.putalpha(badge.getchannel("A").point(lambda a: round(a * opacity)))
margin = max(10, round(canvas.width * BADGE_MARGIN))
canvas.alpha_composite(badge, (canvas.width - size - margin, canvas.height - size - margin))
def _apply_text_watermark(canvas: Image.Image, text: str, opacity: float) -> None:
text = (text or "").strip()
if not text:
return
font_size = max(TEXT_MIN_SIZE, round(canvas.width * TEXT_SCALE))
font = _load_font(font_size)
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
if tw >= canvas.width: # 文案比图还宽:按比例缩字号重排一次
font_size = max(TEXT_MIN_SIZE, round(font_size * canvas.width / tw * 0.94))
font = _load_font(font_size)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
margin = max(10, round(canvas.width * BADGE_MARGIN))
x = canvas.width - tw - margin - bbox[0]
y = canvas.height - th - margin - bbox[1]
stroke = (0, 0, 0, round(255 * STROKE_ALPHA))
fill = (255, 255, 255, 255)
draw.text((x, y), text, font=font, fill=fill, stroke_width=max(1, round(font_size * STROKE_RATIO)),
stroke_fill=stroke)
layer.putalpha(layer.getchannel("A").point(lambda a: round(a * opacity)))
canvas.alpha_composite(layer)
def apply_watermark(data: bytes, opts: dict) -> bytes:
"""给图片字节加水印,返回同格式字节;opts: {type, text, opacity(0-100)}。"""
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
fmt = "PNG" if is_png else "JPEG"
try:
img = Image.open(io.BytesIO(data))
canvas = img.convert("RGBA")
opacity = min(100, max(1, int(opts.get("opacity") or 30))) / 100
if opts.get("type") == "text":
_apply_text_watermark(canvas, opts.get("text") or "", opacity)
else:
_apply_image_watermark(canvas, opacity)
out = io.BytesIO()
if fmt == "PNG":
canvas.save(out, format="PNG")
else:
canvas.convert("RGB").save(out, format="JPEG", quality=95)
return out.getvalue()
except Exception: # noqa: BLE001
log.exception("水印合成失败,返回原图")
return data