- 优化用户资料和笔记详情的数据提取逻辑,优先从 `raw["raw"]["content"]` 获取内容,并回退到 `raw["content"]` - 在笔记详情解析中,增加从 `result["text"]` 提取文本的备用路径 - 在用户动态流解析中,优先从 `f["id"]` 获取笔记 ID,并增加无 ID 条目的日志警告 ✨ feat(persona): 扩展人设池并集成视觉风格配置 - 新增“赛博AI虚拟博主”和“性感福利主播”人设及其对应的主题与关键词 - 在 `sd_service.py` 中新增 `PERSONA_SD_PROFILES` 字典,为每个人设定义视觉增强词、风格后缀和 LLM 绘图指导 - 新增 `get_persona_sd_profile` 函数,根据人设文本匹配对应的视觉配置 ♻️ refactor(llm): 重构 SD 绘图提示词生成以支持人设 - 修改 `LLMService.get_sd_prompt_guide` 函数签名,新增 `persona` 参数 - 在生成的绘图指南中,根据匹配到的人设追加特定的视觉风格指导文本 - 针对“赛博AI虚拟博主”人设,调整反 AI 检测提示,允许使用高质量词汇和专业光效 - 更新所有调用 `get_sd_prompt_guide` 的地方(如文案生成函数),传入 `persona` 参数 ♻️ refactor(sd): 重构文生图服务以支持人设视觉增强 - 修改 `SDService.txt2img` 函数签名,新增 `persona` 参数 - 在生成最终提示词时,注入人设特定的增强词(`prompt_boost`)和风格词(`prompt_style`) - 在生成最终负面提示词时,追加人设特定的额外负面词(`negative_extra`) - 增加人设视觉增强已注入的日志信息 🔧 chore(config): 更新默认人设配置 - 将 `config_manager.py` 中的默认 `persona` 从“身材管理健身美女”更新为“性感福利主播” 🔧 chore(main): 更新 UI 函数签名以传递人设参数 - 更新 `generate_images` 函数签名,新增 `persona_text` 参数,并在内部解析为人设对象 - 更新 `auto_publish_once` 和 `generate_to_queue` 函数中调用 `sd_svc.txt2img` 的地方,传入 `persona` 参数 - 更新 Gradio 界面中 `btn_gen_img` 的点击事件,将 `persona` 输入传递给 `generate_images` 函数
880 lines
39 KiB
Python
880 lines
39 KiB
Python
"""
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
# ==================== 人设 SD 视觉配置 ====================
|
||
# 每个人设对应一组 SD prompt 增强词 + 视觉风格 + LLM 绘图指导
|
||
# key 匹配规则: persona 文本中包含 key 即命中
|
||
|
||
PERSONA_SD_PROFILES = {
|
||
# ---- 赛博AI虚拟博主 ----
|
||
"赛博AI虚拟博主": {
|
||
# 追加到 SD prompt 前面的人设特有增强词
|
||
"prompt_boost": (
|
||
"(perfect face:1.3), (extremely detailed face:1.3), (beautiful detailed eyes:1.3), "
|
||
"(flawless skin:1.2), (ultra high resolution face:1.2), (glossy lips:1.1), "
|
||
),
|
||
# 追加到 SD prompt 后面的风格词
|
||
"prompt_style": (
|
||
", cinematic portrait, fashion editorial, vibrant colors, "
|
||
"dramatic lighting, creative background, fantasy environment, "
|
||
"instagram influencer, trending on artstation"
|
||
),
|
||
# 额外追加到负面提示词
|
||
"negative_extra": "",
|
||
# LLM 绘图指导 (追加到 sd_prompt_guide)
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 赛博AI虚拟博主】\n"
|
||
"你在为一个大方承认自己是AI的虚拟博主生成图片,主打高颜值+场景奇观:\n"
|
||
"- 五官极致精致:重点描述 detailed eyes, long eyelashes, glossy lips, perfect skin\n"
|
||
"- 场景大胆奇幻:巴黎埃菲尔铁塔前、东京霓虹街头、赛博朋克城市、外太空空间站、水下宫殿、樱花隧道等\n"
|
||
"- 光效华丽:neon lights, cyberpunk glow, holographic, lens flare, volumetric fog\n"
|
||
"- 穿搭多变:可大胆使用 futuristic outfit, holographic dress, cyberpunk jacket, maid outfit, school uniform, wedding dress 等\n"
|
||
"- 构图要有视觉冲击力:close-up portrait, dynamic angle, full body shot, looking at viewer\n"
|
||
"- 整体风格:超现实+高颜值,不需要追求真实感,要追求视觉震撼\n"
|
||
),
|
||
},
|
||
# ---- 性感福利主播 ----
|
||
"性感福利主播": {
|
||
"prompt_boost": (
|
||
"(beautiful detailed face:1.3), (seductive eyes:1.2), (glossy lips:1.2), "
|
||
"(perfect body proportions:1.2), (slim waist:1.2), (long legs:1.2), "
|
||
"(glamour photography:1.3), "
|
||
),
|
||
"prompt_style": (
|
||
", soft glamour lighting, beauty retouching, "
|
||
"intimate atmosphere, warm golden tones, shallow depth of field, "
|
||
"boudoir style, sensual but tasteful, fashion model pose"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 性感福利主播】\n"
|
||
"为一个身材曼妙的时尚博主生成图片,主打身材美感+氛围感:\n"
|
||
"- 身材描述:slim waist, long legs, perfect figure, hourglass body, graceful pose\n"
|
||
"- 穿搭关键:lingerie, bikini, bodycon dress, off-shoulder, backless dress, lace, sheer fabric\n"
|
||
"- 光线氛围:soft warm lighting, golden hour, window light, candle light, intimate mood\n"
|
||
"- 场景选择:bedroom, luxury hotel, swimming pool, beach sunset, mirror selfie\n"
|
||
"- 构图要点:full body or three-quarter shot, emphasize curves, elegant pose, looking at viewer\n"
|
||
"- 整体风格:glamour photography,魅力但有品位,不要低俗\n"
|
||
),
|
||
},
|
||
# ---- 身材管理健身美女 ----
|
||
"身材管理健身美女": {
|
||
"prompt_boost": (
|
||
"(fit body:1.3), (athletic physique:1.2), (toned muscles:1.2), "
|
||
"(healthy glow:1.2), (confident pose:1.2), "
|
||
),
|
||
"prompt_style": (
|
||
", fitness photography, gym environment, athletic wear, "
|
||
"dynamic pose, energetic mood, healthy lifestyle, "
|
||
"motivational, natural sweat, workout aesthetic"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 身材管理健身美女】\n"
|
||
"为一个健身达人生成图片,主打健康美+运动感:\n"
|
||
"- 身材描述:fit body, toned abs, lean muscles, athletic build, healthy skin\n"
|
||
"- 穿搭关键:sports bra, yoga pants, running outfit, gym wear, crop top\n"
|
||
"- 场景选择:gym, yoga studio, outdoor running, home workout, mirror selfie at gym\n"
|
||
"- 动作姿势:workout pose, stretching, running, yoga pose, flexing, plank\n"
|
||
"- 光线:gym lighting, natural daylight, morning sun, energetic bright tones\n"
|
||
"- 整体风格:充满活力的运动健身风,展现自律和力量美\n"
|
||
),
|
||
},
|
||
# ---- 温柔知性的时尚博主 ----
|
||
"温柔知性时尚博主": {
|
||
"prompt_boost": (
|
||
"(elegant:1.2), (gentle expression:1.2), (sophisticated:1.2), "
|
||
"(fashion forward:1.2), (graceful:1.1), "
|
||
),
|
||
"prompt_style": (
|
||
", fashion editorial, street style photography, "
|
||
"chic outfit, elegant pose, soft natural tones, "
|
||
"magazine cover quality, lifestyle photography"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 温柔知性时尚博主】\n"
|
||
"为一个知性优雅的时尚博主生成图片,主打高级感穿搭:\n"
|
||
"- 气质描述:elegant, gentle smile, sophisticated, poised, graceful\n"
|
||
"- 穿搭关键:french style, minimalist outfit, trench coat, silk blouse, midi skirt, neutral colors\n"
|
||
"- 场景选择:cafe, art gallery, tree-lined street, bookstore, european architecture\n"
|
||
"- 构图:three-quarter shot, walking pose, looking away candidly, holding coffee\n"
|
||
"- 色调:warm neutral tones, soft creamy, muted colors, film aesthetic\n"
|
||
"- 整体风格:法式优雅+知性温柔,像时尚杂志的生活方式大片\n"
|
||
),
|
||
},
|
||
# ---- 文艺青年摄影师 ----
|
||
"文艺青年摄影师": {
|
||
"prompt_boost": (
|
||
"(artistic:1.2), (moody atmosphere:1.2), (film grain:1.2), "
|
||
"(vintage tones:1.1), "
|
||
),
|
||
"prompt_style": (
|
||
", film photography, 35mm film, kodak portra 400, "
|
||
"vintage color grading, indie aesthetic, dreamy atmosphere, "
|
||
"golden hour, nostalgic mood"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 文艺青年摄影师】\n"
|
||
"为文艺摄影师生成图片,主打胶片感+故事性:\n"
|
||
"- 风格关键:film grain, vintage tones, kodak portra, dreamy, nostalgic\n"
|
||
"- 场景选择:old streets, abandoned places, cafe corner, train station, seaside, flower field\n"
|
||
"- 光线:golden hour, window light, overcast soft light, dappled sunlight\n"
|
||
"- 构图:off-center composition, back view, silhouette, reflection\n"
|
||
"- 色调:warm vintage, faded colors, low contrast, film color grading\n"
|
||
"- 整体风格:独立电影画面感,有故事性的文艺氛围\n"
|
||
),
|
||
},
|
||
# ---- 二次元coser ----
|
||
"二次元coser": {
|
||
"prompt_boost": (
|
||
"(cosplay:1.3), (detailed costume:1.2), (colorful:1.2), "
|
||
"(anime inspired:1.1), (vibrant:1.2), "
|
||
),
|
||
"prompt_style": (
|
||
", cosplay photography, anime convention, colorful costume, "
|
||
"dynamic pose, vibrant colors, fantasy setting, "
|
||
"dramatic lighting, character portrayal"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 二次元coser】\n"
|
||
"为cos博主生成图片,主打二次元还原+视觉冲击:\n"
|
||
"- 风格关键:cosplay, detailed costume, colorful wig, contact lenses, anime style\n"
|
||
"- 场景选择:anime convention, fantasy landscape, school rooftop, cherry blossoms, studio backdrop\n"
|
||
"- 动作姿势:character pose, dynamic action, cute pose, peace sign, holding prop\n"
|
||
"- 光效:colored lighting, rim light, sparkle effects, dramatic shadows\n"
|
||
"- 色调:vibrant saturated, anime color palette, high contrast\n"
|
||
"- 整体风格:真人cos感,兼具二次元的鲜艳感和真实摄影的质感\n"
|
||
),
|
||
},
|
||
# ---- 汉服爱好者 ----
|
||
"汉服爱好者": {
|
||
"prompt_boost": (
|
||
"(traditional chinese dress:1.3), (hanfu:1.3), (chinese aesthetic:1.2), "
|
||
"(elegant traditional:1.2), (delicate accessories:1.1), "
|
||
),
|
||
"prompt_style": (
|
||
", traditional chinese photography, han dynasty style, "
|
||
"ink painting aesthetic, bamboo forest, ancient architecture, "
|
||
"flowing silk fabric, classical beauty, ethereal atmosphere"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 汉服爱好者】\n"
|
||
"为国风汉服博主生成图片,主打古典美+中国风:\n"
|
||
"- 服饰描述:hanfu, flowing silk robes, wide sleeves, hair accessories, jade earrings, fan\n"
|
||
"- 场景选择:bamboo forest, ancient temple, moon gate, lotus pond, plum blossom, mountain mist\n"
|
||
"- 光线:soft diffused light, misty atmosphere, morning fog, moonlight, lantern glow\n"
|
||
"- 构图:full body flowing fabric, profile view, looking down gently, holding umbrella\n"
|
||
"- 色调:muted earth tones, ink wash style, red and white contrast, jade green\n"
|
||
"- 整体风格:仙气飘飘的古风摄影,有水墨画的意境\n"
|
||
),
|
||
},
|
||
# ---- 独居女孩 ----
|
||
"独居女孩": {
|
||
"prompt_boost": (
|
||
"(cozy atmosphere:1.3), (warm lighting:1.2), (homey:1.2), "
|
||
"(casual style:1.1), (relaxed:1.1), "
|
||
),
|
||
"prompt_style": (
|
||
", cozy home photography, warm ambient light, "
|
||
"casual indoor style, hygge aesthetic, "
|
||
"soft blanket, candle light, peaceful morning"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 独居女孩】\n"
|
||
"为独居生活博主生成图片,主打温馨氛围感+仪式感:\n"
|
||
"- 氛围关键:cozy, warm, hygge, peaceful, intimate, ambient candlelight\n"
|
||
"- 场景选择:small apartment, kitchen cooking, bathtub, reading by window, balcony garden\n"
|
||
"- 穿搭关键:oversized sweater, pajamas, casual homewear, messy bun\n"
|
||
"- 光线:warm lamp light, candle glow, morning window light, fairy lights\n"
|
||
"- 道具:coffee mug, book, cat, houseplant, scented candle, blanket\n"
|
||
"- 整体风格:温暖治愈的独居日常,有仪式感的精致生活\n"
|
||
),
|
||
},
|
||
# ---- 资深美妆博主 ----
|
||
"资深美妆博主": {
|
||
"prompt_boost": (
|
||
"(flawless makeup:1.3), (detailed eye makeup:1.3), (beauty close-up:1.2), "
|
||
"(perfect skin:1.2), (beauty lighting:1.2), "
|
||
),
|
||
"prompt_style": (
|
||
", beauty photography, ring light, macro lens, "
|
||
"studio beauty lighting, makeup tutorial style, "
|
||
"dewy skin, perfect complexion, vibrant lip color"
|
||
),
|
||
"negative_extra": "",
|
||
"llm_guide": (
|
||
"\n\n【人设视觉风格 - 资深美妆博主】\n"
|
||
"为美妆博主生成图片,主打妆容特写+产品展示:\n"
|
||
"- 妆容描述:detailed eye shadow, winged eyeliner, glossy lips, dewy foundation, blush\n"
|
||
"- 构图要点:face close-up, half face, eye close-up, lip close-up, before and after\n"
|
||
"- 场景选择:vanity desk, bathroom mirror, ring light studio, flat lay of products\n"
|
||
"- 光线:ring light, beauty dish, soft diffused studio light, bright even lighting\n"
|
||
"- 色调:clean bright, pink tones, neutral with pops of color\n"
|
||
"- 整体风格:专业美妆教程感,妆容细节清晰可见\n"
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
def get_persona_sd_profile(persona_text: str) -> dict | None:
|
||
"""根据人设文本匹配 SD 视觉配置,返回 profile dict 或 None"""
|
||
if not persona_text:
|
||
return None
|
||
for key, profile in PERSONA_SD_PROFILES.items():
|
||
if key in persona_text:
|
||
return profile
|
||
return None
|
||
|
||
|
||
# 默认配置 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,
|
||
persona: str = None,
|
||
) -> list[Image.Image]:
|
||
"""文生图(自动适配当前 SD 模型 + 人设的最优参数)
|
||
|
||
Args:
|
||
model: SD 模型名,自动识别并应用对应配置
|
||
face_image: 头像 PIL Image,传入后自动启用 ReActor 换脸
|
||
quality_mode: 预设模式名
|
||
persona: 博主人设文本,自动注入人设视觉增强词
|
||
"""
|
||
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 + 模型后缀 + 人设风格
|
||
persona_sd = get_persona_sd_profile(persona)
|
||
persona_boost = persona_sd.get("prompt_boost", "") if persona_sd else ""
|
||
persona_style = persona_sd.get("prompt_style", "") if persona_sd else ""
|
||
enhanced_prompt = persona_boost + profile.get("prompt_prefix", "") + prompt + profile.get("prompt_suffix", "") + persona_style
|
||
|
||
if persona_sd:
|
||
logger.info("🎭 人设视觉增强已注入: +%d boost词 +%d style词",
|
||
len(persona_boost), len(persona_style))
|
||
|
||
# 使用模型专属反向提示词 + 人设额外负面词
|
||
final_negative = negative_prompt if negative_prompt is not None else profile.get("negative_prompt", DEFAULT_NEGATIVE)
|
||
if persona_sd and persona_sd.get("negative_extra"):
|
||
final_negative = final_negative + ", " + persona_sd["negative_extra"]
|
||
|
||
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 []
|