""" 图片后处理(去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