Files
beige/backend/app/services/ai_engine/image_postprocessor.py
yangqianqian 6a2632da70 baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:30:22 +08:00

178 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
图片后处理去AI化主路
对齐大卫 xhs-tool/backend/infrastructure/imagePostProcess.js运营实测去AI化版
主路 = 尺寸可选(±2%容差内不resize) + SynthID破除(可选) + 高保真重编码去元数据。
诚实声明C2PA 元数据可去除;私有像素水印(如 SynthID只能削弱不保证 100% 清除。
"""
from __future__ import annotations
import io
import logging
import os
logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageEnhance, ImageOps
_PILLOW_OK = True
except ImportError:
_PILLOW_OK = False
logger.warning("Pillow 未安装image_postprocessor 不可用")
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
RATIO_MAP: dict[str, tuple[int, int]] = {
"1:1": (1024, 1024),
"3:4": (1024, 1536), # gpt-image-2 原生尺寸,默认
"4:3": (1536, 1024),
"9:16": (864, 1536),
"16:9": (1536, 864),
}
# ±2% 容差内不做 resize避免无谓重采样对齐大卫 diff > 0.02 才 resize
_RATIO_TOLERANCE = 0.02
def _need_resize(actual_w: int, actual_h: int, target_w: int, target_h: int) -> bool:
"""判断实际比例与目标比例差距是否超出容差。"""
actual_ratio = actual_w / actual_h
target_ratio = target_w / target_h
diff = abs(actual_ratio - target_ratio) / target_ratio
return diff > _RATIO_TOLERANCE
def process_image(
image_bytes: bytes,
aspect_ratio: str = "3:4",
resample_strength: int = 1, # 0=不重采样, 1=轻采样(默认), 2=重采样
) -> bytes:
"""
处理单张图片。
参数:
image_bytes — 原始图片 bytesPNG/JPEG/WebP 等)
aspect_ratio — 目标比例,取 RATIO_MAP 的 key默认 '3:4'=1024×1536
resample_strength — 轻重采样削像素水印0/1/2默认 1=轻采样
返回 JPEG bytes无 EXIF/C2PA/XMP 元数据)。
失败时降级返回原图 bytes不抛异常对齐大卫 catch 返回原图)。
"""
if not _PILLOW_OK:
logger.error("Pillow 未安装,跳过后处理,返回原图")
return image_bytes
try:
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
actual_w, actual_h = img.size
target = RATIO_MAP.get(aspect_ratio)
# --- Step1: 尺寸对齐±2% 容差内跳过 resize---
if target:
tw, th = target
if _need_resize(actual_w, actual_h, tw, th):
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
logger.debug("resize %dx%d%dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
img = _apply_resample(img, resample_strength)
# --- Step3: SynthID 破除SYNTHID_HARD_MODE=1 才开,默认关)---
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
img = _apply_synthid_break(img, target)
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=100,
subsampling=0, # 4:4:4 chroma
optimize=True,
# 不传 exif/icc_profile/xmp = 不写入任何元数据
)
result = buf.getvalue()
logger.debug("后处理完成 %d B → %d B (ratio=%s)", len(image_bytes), len(result), aspect_ratio)
return result
except Exception as exc:
logger.warning("图片后处理失败,降级返回原图: %s", exc)
return image_bytes
def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
"""
轻/重采样削像素级水印resample_strength 控制)。
0 — 不采样,仅靠重编码去元数据。
1 — 轻采样缩98%再回原尺寸,保视觉质量,削弱像素水印(对齐旧逻辑)。
2 — 重采样:两次缩放,削弱更多,轻微质量损失。
"""
if strength < 1:
return img
w, h = img.size
img = img.resize((int(w * 0.98), int(h * 0.98)), Image.LANCZOS)
img = img.resize((w, h), Image.LANCZOS)
if strength >= 2:
img = img.resize((int(w * 0.96), int(h * 0.96)), Image.LANCZOS)
img = img.resize((w, h), Image.LANCZOS)
return img
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
"""
SynthID 破除SYNTHID_HARD_MODE=1 时调用):
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
诚实声明:只能削弱 SynthID不保证 100% 清除。
"""
tw, th = target
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
# 裁掉1px边消除边缘水印残留
img = img.crop((1, 1, tw - 3, th - 3))
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation
img = ImageEnhance.Brightness(img).enhance(1.005)
img = ImageEnhance.Color(img).enhance(0.998)
return img
def batch_process(
images: list[bytes],
aspect_ratio: str = "3:4",
resample_strength: int = 1,
) -> list[dict]:
"""
批量后处理。返回 [{index, data, error}],单张失败不阻塞其余。
"""
results = []
for i, img_bytes in enumerate(images):
try:
processed = process_image(img_bytes, aspect_ratio=aspect_ratio,
resample_strength=resample_strength)
results.append({"index": i, "data": processed, "error": None})
except Exception as exc:
logger.error("图片[%d]后处理失败: %s", i, exc)
results.append({"index": i, "data": img_bytes, "error": str(exc)})
return results
async def gemini_rewatermark_fallback(
client: "Any", # GeminiClient由 worker 注入
image_bytes: bytes,
) -> bytes:
"""
备选路Gemini 重绘去水印。
⚠️ 对海报中文大字有改字风险,仅特殊场景启用。
"""
prompt = (
"Remove all watermarks, text overlays, and digital signatures from this image. "
"Reconstruct any covered areas naturally to match the surrounding content. "
"Return a clean version of the same image without any watermarks."
)
try:
result = await client.gemini_generate(
prompt, [image_bytes], "gemini-2.0-flash-preview-image-generation"
)
return result
except Exception as exc:
logger.error("Gemini 去水印失败,降级返回原图: %s", exc)
return image_bytes