将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""
|
||
图片后处理(去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 — 原始图片 bytes(PNG/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
|