xhs_factory/sd_service.py
zhoujie 0c91c00dcf feat(llm): 增强文案反 AI 检测能力并优化图片后处理
- 在系统提示词中新增【反AI检测规则】章节,包含句子长度、逻辑跳跃、标点符号等8项防检测措施
- 为所有SD模型提示词指南添加通用反AI检测技巧,强调真实手机拍摄风格
- 深度重构 `_humanize_content` 方法,新增8层真人化处理:替换书面表达、打散句子长度、随机添加口语元素、模拟手机打字标点习惯
- 增强 `_humanize` 方法,去除更多AI前缀,随机化标点,限制表情符号堆叠
- 在 `sd_service.py` 新增 `anti_detect_postprocess` 图片后处理管线,包含元数据剥离、随机裁剪、色彩微扰、不均匀噪声、JPEG压缩回环等7步处理
- 所有图片生成后自动经过反检测处理,输出格式统一为JPEG以模拟真实手机照片
- 更新 `main.py` 中的图片保存逻辑,统一使用JPEG格式并确保RGB模式转换
2026-02-10 21:37:03 +08:00

639 lines
26 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.

"""
Stable Diffusion 服务模块
封装对 SD WebUI API 的调用,支持 txt2img 和 img2img支持 ReActor 换脸
含图片反 AI 检测后处理管线
"""
import requests
import base64
import io
import logging
import os
import random
import math
import struct
import zlib
from PIL import Image, ImageFilter, ImageEnhance
logger = logging.getLogger(__name__)
SD_TIMEOUT = 1800 # 图片生成可能需要较长时间
# 头像文件默认保存路径
FACE_IMAGE_PATH = os.path.join(os.path.dirname(__file__), "my_face.png")
# ==================== 多模型配置系统 ====================
# 每个模型的最优参数、prompt 增强词、负面提示词、三档预设
SD_MODEL_PROFILES = {
# ---- majicmixRealistic: 东亚网红感,朋友圈自拍/美妆/穿搭 (SD 1.5) ----
"majicmixRealistic": {
"display_name": "majicmixRealistic ⭐⭐⭐⭐⭐",
"description": "东亚网红感 | 朋友圈自拍、美妆、穿搭",
"arch": "sd15", # SD 1.5 架构
# 自动追加到 prompt 前面的增强词
"prompt_prefix": (
"(best quality:1.4), (masterpiece:1.4), (ultra detailed:1.3), "
"(photorealistic:1.4), (realistic:1.3), raw photo, "
"(asian girl:1.3), (chinese:1.2), (east asian features:1.2), "
"(delicate facial features:1.2), (fair skin:1.1), (natural skin texture:1.2), "
"(soft lighting:1.1), (natural makeup:1.1), "
),
# 自动追加到 prompt 后面的补充词
"prompt_suffix": (
", film grain, shallow depth of field, "
"instagram aesthetic, xiaohongshu style, phone camera feel"
),
"negative_prompt": (
"(nsfw:1.5), (nudity:1.5), (worst quality:2), (low quality:2), (normal quality:2), "
"lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, "
"blurry, deformed, mutated, disfigured, ugly, duplicate, "
"poorly drawn face, poorly drawn hands, extra limbs, fused fingers, "
"too many fingers, long neck, out of frame, "
"western face, european face, caucasian, deep-set eyes, high nose bridge, "
"blonde hair, red hair, blue eyes, green eyes, freckles, thick body hair, "
"painting, cartoon, anime, sketch, illustration, 3d render"
),
"presets": {
"快速 (约30秒)": {
"steps": 20,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 30,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 40,
"cfg_scale": 7.5,
"width": 576,
"height": 864,
"sampler_name": "DPM++ SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
# ---- Realistic Vision: 写实摄影感,纪实摄影/街拍/真实质感 (SD 1.5) ----
"realisticVision": {
"display_name": "Realistic Vision ⭐⭐⭐⭐",
"description": "写实摄影感 | 纪实摄影、街拍、真实质感",
"arch": "sd15",
"prompt_prefix": (
"RAW photo, (best quality:1.4), (masterpiece:1.3), (realistic:1.4), "
"(photorealistic:1.4), 8k uhd, DSLR, high quality, "
"(asian:1.2), (chinese girl:1.2), (east asian features:1.1), "
"(natural skin:1.2), (skin pores:1.1), (detailed skin texture:1.2), "
),
"prompt_suffix": (
", shot on Canon EOS R5, 85mm lens, f/1.8, "
"natural lighting, documentary style, street photography, "
"film color grading, depth of field"
),
"negative_prompt": (
"(nsfw:1.5), (nudity:1.5), (worst quality:2), (low quality:2), (normal quality:2), "
"lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, "
"blurry, deformed, mutated, disfigured, ugly, duplicate, "
"poorly drawn face, extra limbs, fused fingers, long neck, "
"western face, european face, caucasian, deep-set eyes, "
"blonde hair, blue eyes, green eyes, freckles, "
"painting, cartoon, anime, sketch, illustration, 3d render, "
"over-sharpened, over-saturated, plastic skin, airbrushed, "
"smooth skin, doll-like, HDR, overprocessed"
),
"presets": {
"快速 (约30秒)": {
"steps": 20,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 28,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 40,
"cfg_scale": 7.5,
"width": 576,
"height": 864,
"sampler_name": "DPM++ SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
# ---- Juggernaut XL: 电影大片感,高画质/商业摄影/复杂背景 (SDXL) ----
"juggernautXL": {
"display_name": "Juggernaut XL ⭐⭐⭐⭐",
"description": "电影大片感 | 高画质、商业摄影、复杂背景",
"arch": "sdxl", # SDXL 架构
"prompt_prefix": (
"masterpiece, best quality, ultra detailed, 8k uhd, high resolution, "
"photorealistic, cinematic lighting, cinematic composition, "
"asian girl, chinese, east asian features, black hair, dark brown eyes, "
"delicate facial features, fair skin, slim figure, "
),
"prompt_suffix": (
", cinematic color grading, anamorphic lens, bokeh, "
"volumetric lighting, ray tracing, global illumination, "
"commercial photography, editorial style, vogue aesthetic"
),
"negative_prompt": (
"nsfw, nudity, lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, worst quality, low quality, normal quality, "
"jpeg artifacts, signature, watermark, blurry, deformed, mutated, disfigured, "
"ugly, duplicate, morbid, mutilated, poorly drawn face, poorly drawn hands, "
"extra limbs, fused fingers, too many fingers, long neck, username, "
"out of frame, distorted, oversaturated, underexposed, overexposed, "
"western face, european face, caucasian, deep-set eyes, high nose bridge, "
"blonde hair, red hair, blue eyes, green eyes, freckles, thick body hair"
),
"presets": {
"快速 (约30秒)": {
"steps": 12,
"cfg_scale": 5.0,
"width": 768,
"height": 1024,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 20,
"cfg_scale": 5.5,
"width": 832,
"height": 1216,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 35,
"cfg_scale": 6.0,
"width": 832,
"height": 1216,
"sampler_name": "DPM++ 2M SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
}
# 默认配置 profile key
DEFAULT_MODEL_PROFILE = "juggernautXL"
def detect_model_profile(model_name: str) -> str:
"""根据 SD 模型名称自动识别对应的 profile key"""
name_lower = model_name.lower() if model_name else ""
if "majicmix" in name_lower or "majic" in name_lower:
return "majicmixRealistic"
elif "realistic" in name_lower and "vision" in name_lower:
return "realisticVision"
elif "rv" in name_lower and ("v5" in name_lower or "v6" in name_lower or "v4" in name_lower):
return "realisticVision" # RV v5.1 等简写
elif "juggernaut" in name_lower or "jugger" in name_lower:
return "juggernautXL"
# 根据架构猜测
elif "xl" in name_lower or "sdxl" in name_lower:
return "juggernautXL" # SDXL 架构默认用 Juggernaut 参数
else:
return DEFAULT_MODEL_PROFILE # 无法识别时默认
def get_model_profile(model_name: str = None) -> dict:
"""获取模型配置 profile"""
key = detect_model_profile(model_name) if model_name else DEFAULT_MODEL_PROFILE
return SD_MODEL_PROFILES.get(key, SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE])
def get_model_profile_info(model_name: str = None) -> str:
"""获取当前模型的显示信息 (Markdown 格式)"""
profile = get_model_profile(model_name)
key = detect_model_profile(model_name) if model_name else DEFAULT_MODEL_PROFILE
is_default = key == DEFAULT_MODEL_PROFILE and model_name and detect_model_profile(model_name) == DEFAULT_MODEL_PROFILE
# 如果检测结果是默认回退的, 说明是未知模型
actual_key = detect_model_profile(model_name) if model_name else None
presets = profile["presets"]
first_preset = list(presets.values())[0]
res = f"{first_preset.get('width', '?')}×{first_preset.get('height', '?')}"
lines = [
f"**🎨 {profile['display_name']}** | `{profile['arch'].upper()}` | {res}",
f"> {profile['description']}",
]
if model_name and not any(k in (model_name or "").lower() for k in ["majicmix", "realistic", "juggernaut"]):
lines.append(f"> ⚠️ 未识别的模型,使用默认档案 ({profile['display_name']})")
return "\n".join(lines)
# ==================== 兼容旧接口 ====================
# 默认预设和反向提示词 (使用 Juggernaut XL 作为默认)
SD_PRESETS = SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE]["presets"]
SD_PRESET_NAMES = list(SD_PRESETS.keys())
def get_sd_preset(name: str, model_name: str = None) -> dict:
"""获取生成预设参数,自动适配模型"""
profile = get_model_profile(model_name)
presets = profile.get("presets", SD_PRESETS)
return presets.get(name, presets.get("标准 (约1分钟)", list(presets.values())[0]))
# 默认反向提示词Juggernaut XL
DEFAULT_NEGATIVE = SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE]["negative_prompt"]
# ==================== 图片反 AI 检测管线 ====================
def anti_detect_postprocess(img: Image.Image) -> Image.Image:
"""对 AI 生成的图片进行后处理,模拟手机拍摄/加工特征,降低 AI 检测率
处理流程:
1. 剥离所有元数据 (EXIF/SD参数/PNG chunks)
2. 微小随机裁剪 (模拟手机截图不完美)
3. 微小旋转+校正 (破坏像素完美对齐)
4. 色彩微扰 (模拟手机屏幕色差)
5. 不均匀高斯噪声 (模拟传感器噪声)
6. 微小锐化 (模拟手机锐化算法)
7. JPEG 压缩回环 (最关键: 引入真实压缩伪影)
"""
if img.mode != "RGB":
img = img.convert("RGB")
w, h = img.size
# Step 1: 微小随机裁剪 (1-3 像素, 破坏边界对齐)
crop_l = random.randint(0, 3)
crop_t = random.randint(0, 3)
crop_r = random.randint(0, 3)
crop_b = random.randint(0, 3)
if crop_l + crop_r < w and crop_t + crop_b < h:
img = img.crop((crop_l, crop_t, w - crop_r, h - crop_b))
# Step 2: 极微旋转 (0.1°-0.5°, 破坏完美像素排列)
if random.random() < 0.6:
angle = random.uniform(-0.5, 0.5)
img = img.rotate(angle, resample=Image.BICUBIC, expand=False,
fillcolor=(
random.randint(240, 255),
random.randint(240, 255),
random.randint(240, 255),
))
# Step 3: 色彩微扰 (模拟手机屏幕/相机色差)
# 亮度微调
brightness_factor = random.uniform(0.97, 1.03)
img = ImageEnhance.Brightness(img).enhance(brightness_factor)
# 对比度微调
contrast_factor = random.uniform(0.97, 1.03)
img = ImageEnhance.Contrast(img).enhance(contrast_factor)
# 饱和度微调
saturation_factor = random.uniform(0.96, 1.04)
img = ImageEnhance.Color(img).enhance(saturation_factor)
# Step 4: 不均匀传感器噪声 (比均匀噪声更像真实相机)
try:
import numpy as np
arr = np.array(img, dtype=np.float32)
# 生成不均匀噪声: 中心弱边缘强 (模拟暗角)
h_arr, w_arr = arr.shape[:2]
y_grid, x_grid = np.mgrid[0:h_arr, 0:w_arr]
center_y, center_x = h_arr / 2, w_arr / 2
dist = np.sqrt((y_grid - center_y) ** 2 + (x_grid - center_x) ** 2)
max_dist = np.sqrt(center_y ** 2 + center_x ** 2)
# 噪声强度: 中心 1.0, 边缘 2.5
noise_strength = 1.0 + 1.5 * (dist / max_dist)
noise_strength = noise_strength[:, :, np.newaxis]
# 高斯噪声
noise = np.random.normal(0, random.uniform(1.5, 3.0), arr.shape) * noise_strength
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
img = Image.fromarray(arr)
except ImportError:
# numpy 不可用时用 PIL 的简单模糊代替
pass
# Step 5: 轻微锐化 (模拟手机后处理)
if random.random() < 0.5:
img = img.filter(ImageFilter.SHARPEN)
# 再做一次轻微模糊中和, 避免过度锐化
img = img.filter(ImageFilter.GaussianBlur(radius=0.3))
# Step 6: JPEG 压缩回环 (最关键! 引入真实压缩伪影)
# 模拟: 手机保存 → 社交平台压缩 → 重新上传
quality = random.randint(85, 93) # 质量略低于完美, 像手机存储
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality, subsampling=0)
buf.seek(0)
img = Image.open(buf).copy() # 重新加载, 已包含 JPEG 伪影
# Step 7: resize 回原始尺寸附近 (模拟平台缩放)
# 微小缩放 ±2%
scale = random.uniform(0.98, 1.02)
new_w = int(img.width * scale)
new_h = int(img.height * scale)
if new_w > 100 and new_h > 100:
img = img.resize((new_w, new_h), Image.LANCZOS)
logger.info("🛡️ 图片反检测后处理完成: crop=%dx%d%dx%d, jpeg_q=%d, scale=%.2f",
w, h, img.width, img.height, quality, scale)
return img
def strip_metadata(img: Image.Image) -> Image.Image:
"""彻底剥离图片所有元数据 (EXIF, SD参数, PNG text chunks)"""
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
return clean
class SDService:
"""Stable Diffusion WebUI API 封装"""
def __init__(self, sd_url: str = "http://127.0.0.1:7860"):
self.sd_url = sd_url.rstrip("/")
# ---------- 工具方法 ----------
@staticmethod
def _image_to_base64(img: Image.Image) -> str:
"""PIL Image → base64 字符串"""
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
@staticmethod
def load_face_image(path: str = None) -> Image.Image | None:
"""加载头像图片,不存在则返回 None"""
path = path or FACE_IMAGE_PATH
if path and os.path.isfile(path):
try:
return Image.open(path).convert("RGB")
except Exception as e:
logger.warning("头像加载失败: %s", e)
return None
@staticmethod
def save_face_image(img: Image.Image, path: str = None) -> str:
"""保存头像图片,返回保存路径"""
path = path or FACE_IMAGE_PATH
img = img.convert("RGB")
img.save(path, format="PNG")
logger.info("头像已保存: %s", path)
return path
def _build_reactor_args(self, face_image: Image.Image) -> dict:
"""构建 ReActor 换脸参数alwayson_scripts 格式)
参数索引对照 (reactor script-info):
0: source_image (base64) 1: enable 2: source_faces
3: target_faces 4: model 5: restore_face
6: restore_visibility 7: restore_first 8: upscaler
9: scale 10: upscaler_vis 11: swap_in_source
12: swap_in_generated 13: log_level 14: gender_source
15: gender_target 16: save_original 17: codeformer_weight
18: source_hash_check 19: target_hash_check 20: exec_provider
21: face_mask_correction 22: select_source 23: face_model
24: source_folder 25: multiple_sources 26: random_image
27: force_upscale 28: threshold 29: max_faces
30: tab_single
"""
face_b64 = self._image_to_base64(face_image)
return {
"reactor": {
"args": [
face_b64, # 0: source image (base64)
True, # 1: enable ReActor
"0", # 2: source face index
"0", # 3: target face index
"inswapper_128.onnx", # 4: swap model
"CodeFormer", # 5: restore face method
1, # 6: restore face visibility
True, # 7: restore face first, then upscale
"None", # 8: upscaler
1, # 9: scale
1, # 10: upscaler visibility
False, # 11: swap in source
True, # 12: swap in generated
1, # 13: console log level (0=min, 1=med, 2=max)
0, # 14: gender detection source (0=No)
0, # 15: gender detection target (0=No)
False, # 16: save original
0.8, # 17: CodeFormer weight (0=max effect, 1=min)
False, # 18: source hash check
False, # 19: target hash check
"CUDA", # 20: execution provider
True, # 21: face mask correction
0, # 22: select source (0=Image, 1=FaceModel, 2=Folder)
"", # 23: face model filename (when #22=1)
"", # 24: source folder path (when #22=2)
None, # 25: skip for API
False, # 26: random image
False, # 27: force upscale
0.6, # 28: face detection threshold
2, # 29: max faces to detect (0=unlimited)
],
}
}
def has_reactor(self) -> bool:
"""检查 SD WebUI 是否安装了 ReActor 扩展"""
try:
resp = requests.get(f"{self.sd_url}/sdapi/v1/scripts", timeout=5)
scripts = resp.json()
all_scripts = scripts.get("txt2img", []) + scripts.get("img2img", [])
return any("reactor" in s.lower() for s in all_scripts)
except Exception:
return False
def check_connection(self) -> tuple[bool, str]:
"""检查 SD 服务是否可用"""
try:
resp = requests.get(f"{self.sd_url}/sdapi/v1/sd-models", timeout=5)
if resp.status_code == 200:
count = len(resp.json())
return True, f"SD 已连接,{count} 个模型可用"
return False, f"SD 返回异常状态: {resp.status_code}"
except requests.exceptions.ConnectionError:
return False, "SD WebUI 未启动或端口错误"
except Exception as e:
return False, f"SD 连接失败: {e}"
def get_models(self) -> list[str]:
"""获取 SD 模型列表"""
resp = requests.get(f"{self.sd_url}/sdapi/v1/sd-models", timeout=5)
resp.raise_for_status()
return [m["title"] for m in resp.json()]
def switch_model(self, model_name: str):
"""切换 SD 模型"""
try:
requests.post(
f"{self.sd_url}/sdapi/v1/options",
json={"sd_model_checkpoint": model_name},
timeout=60,
)
except Exception as e:
logger.warning("模型切换失败: %s", e)
def txt2img(
self,
prompt: str,
negative_prompt: str = None,
model: str = None,
steps: int = None,
cfg_scale: float = None,
width: int = None,
height: int = None,
batch_size: int = None,
seed: int = -1,
sampler_name: str = None,
scheduler: str = None,
face_image: Image.Image = None,
quality_mode: str = None,
) -> list[Image.Image]:
"""文生图(自动适配当前 SD 模型的最优参数)
Args:
model: SD 模型名,自动识别并应用对应配置
face_image: 头像 PIL Image传入后自动启用 ReActor 换脸
quality_mode: 预设模式名
"""
if model:
self.switch_model(model)
# 自动识别模型配置
profile = get_model_profile(model)
profile_key = detect_model_profile(model)
logger.info("🎯 SD 模型识别: %s%s (%s)",
model or "默认", profile_key, profile["description"])
# 加载模型专属预设参数
preset = get_sd_preset(quality_mode, model) if quality_mode else get_sd_preset("标准 (约1分钟)", model)
# 自动增强 prompt: 前缀 + 原始 prompt + 后缀
enhanced_prompt = profile.get("prompt_prefix", "") + prompt + profile.get("prompt_suffix", "")
# 使用模型专属反向提示词
final_negative = negative_prompt if negative_prompt is not None else profile.get("negative_prompt", DEFAULT_NEGATIVE)
payload = {
"prompt": enhanced_prompt,
"negative_prompt": final_negative,
"steps": steps if steps is not None else preset["steps"],
"cfg_scale": cfg_scale if cfg_scale is not None else preset["cfg_scale"],
"width": width if width is not None else preset["width"],
"height": height if height is not None else preset["height"],
"batch_size": batch_size if batch_size is not None else preset["batch_size"],
"seed": seed,
"sampler_name": sampler_name if sampler_name is not None else preset["sampler_name"],
"scheduler": scheduler if scheduler is not None else preset["scheduler"],
}
logger.info("SD 生成参数 [%s]: steps=%s, cfg=%.1f, %dx%d, sampler=%s",
profile_key, payload['steps'], payload['cfg_scale'],
payload['width'], payload['height'], payload['sampler_name'])
# 如果提供了头像,通过 ReActor 换脸
if face_image is not None:
payload["alwayson_scripts"] = self._build_reactor_args(face_image)
logger.info("🎭 ReActor 换脸已启用")
resp = requests.post(
f"{self.sd_url}/sdapi/v1/txt2img",
json=payload,
timeout=SD_TIMEOUT,
)
resp.raise_for_status()
images = []
for img_b64 in resp.json().get("images", []):
img = Image.open(io.BytesIO(base64.b64decode(img_b64)))
# 反 AI 检测后处理: 剥离元数据 + 模拟手机拍摄特征
img = anti_detect_postprocess(img)
images.append(img)
return images
def img2img(
self,
init_image: Image.Image,
prompt: str,
negative_prompt: str = None,
denoising_strength: float = 0.5,
steps: int = 30,
cfg_scale: float = None,
sampler_name: str = None,
scheduler: str = None,
model: str = None,
) -> list[Image.Image]:
"""图生图(自动适配模型参数)"""
profile = get_model_profile(model)
preset = get_sd_preset("标准 (约1分钟)", model)
# 将 PIL Image 转为 base64
buf = io.BytesIO()
init_image.save(buf, format="PNG")
init_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
enhanced_prompt = profile.get("prompt_prefix", "") + prompt + profile.get("prompt_suffix", "")
final_negative = negative_prompt if negative_prompt is not None else profile.get("negative_prompt", DEFAULT_NEGATIVE)
payload = {
"init_images": [init_b64],
"prompt": enhanced_prompt,
"negative_prompt": final_negative,
"denoising_strength": denoising_strength,
"steps": steps,
"cfg_scale": cfg_scale if cfg_scale is not None else preset["cfg_scale"],
"width": init_image.width,
"height": init_image.height,
"sampler_name": sampler_name if sampler_name is not None else preset["sampler_name"],
"scheduler": scheduler if scheduler is not None else preset["scheduler"],
}
resp = requests.post(
f"{self.sd_url}/sdapi/v1/img2img",
json=payload,
timeout=SD_TIMEOUT,
)
resp.raise_for_status()
images = []
for img_b64 in resp.json().get("images", []):
img = Image.open(io.BytesIO(base64.b64decode(img_b64)))
# 反 AI 检测后处理
img = anti_detect_postprocess(img)
images.append(img)
return images
def get_lora_models(self) -> list[str]:
"""获取可用的 LoRA 模型列表"""
try:
resp = requests.get(f"{self.sd_url}/sdapi/v1/loras", timeout=5)
resp.raise_for_status()
return [lora["name"] for lora in resp.json()]
except Exception:
return []