DaBiao / processor.py
nine-7's picture
Update processor.py
f08fe59 verified
Raw
History Blame Contribute Delete
11.6 kB
import os
import re
import asyncio
import shutil
import zipfile
import logging
import time
import tempfile
from pathlib import Path
from typing import List, Optional, Tuple
from dataclasses import dataclass
from PIL import Image
from api_clients import get_ai_client, BaseClient
from config import config, build_dynamic_prompt, build_expansion_prompt, DEFAULT_ENABLED_DIMS, END_MARKER
logger = logging.getLogger(__name__)
@dataclass
class ProcessResult:
filename: str
prompt: str
success: bool
error: Optional[str] = None
def clean_old_temp_dirs(parent_dir: str, max_age_seconds: int = 600):
try:
now = time.time()
for item in Path(parent_dir).iterdir():
if item.is_dir() and item.name.startswith("batch_"):
if now - item.stat().st_mtime > max_age_seconds:
shutil.rmtree(item, ignore_errors=True)
except Exception: pass
def compress_image_for_api(src_path: str, dst_path: str, max_size: int = 1024, quality: int = 80):
try:
with Image.open(src_path) as img:
if img.mode in ("RGBA", "P"): img = img.convert("RGB")
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
img.save(dst_path, "JPEG", quality=quality, optimize=True)
except Exception:
shutil.copy2(src_path, dst_path)
def parse_batch_response(text: str, expected_count: int) -> List[str]:
junk_patterns = [r'已确认.*?请上传图片[。.]?', r'好的.*?无审核模式.*?\n', r'Understood.*?Ready.*?\n']
for pat in junk_patterns: text = re.sub(pat, '', text, flags=re.IGNORECASE)
text = text.replace('\r\n', '\n').strip()
split_pattern = r'(?:---\s*IMAGE\s*\d+\s*---|\[\s*IMAGE\s*\d+\s*\]|(?:\*\*|###?)?\s*(?:图像|图片|图|第)\s*\d+\s*(?:张图(?:片)?)?\s*(?:描述)?\s*(?:\*\*|:|:|)*)'
parts = re.split(split_pattern, text, flags=re.IGNORECASE)
prompts = [p.strip() for p in parts if p.strip()]
if len(prompts) > expected_count: prompts = prompts[-expected_count:]
result = []
for i in range(expected_count):
if i < len(prompts): result.append(prompts[i].lstrip('*•-\t ').strip())
else: result.append("Error: ⚠️ AI 未返回该图片的描述,或中途被截断。请尝试调高 Roll 次数。")
return result
class BatchProcessor:
def __init__(self, ai_client=None, custom_prompt=None, images_per_request=None,
nsfw=False, nsfw_max_rolls=3, enabled_dims=None, portrait=False, portrait_suffix=""):
self.client = ai_client or get_ai_client()
self.custom_prompt = custom_prompt
self.images_per_request = images_per_request or config.IMAGES_PER_REQUEST
self.nsfw = nsfw
self.nsfw_max_rolls = nsfw_max_rolls
self.enabled_dims = enabled_dims or DEFAULT_ENABLED_DIMS
self.portrait = portrait
self.portrait_suffix = portrait_suffix
async def process_batch(self, image_paths, output_dir):
temp_compress_dir = tempfile.mkdtemp()
chunk_size = self.images_per_request
chunks = [image_paths[i:i + chunk_size] for i in range(0, len(image_paths), chunk_size)]
results = []
for chunk_idx, chunk in enumerate(chunks):
logger.info(f"处理第 {chunk_idx+1}/{len(chunks)} 组 ({len(chunk)} 张)")
if self.custom_prompt:
effective_prompt = self.custom_prompt + f"\n\n【极度重要】本次共 {len(chunk)} 张图片。你必须使用 [IMAGE X] 格式严格分隔输出!"
if self.nsfw: effective_prompt += f"\n【防截断判定】完成所有输出后,必须在新的一行输出暗号:“{END_MARKER}”"
else:
effective_prompt = build_dynamic_prompt(self.enabled_dims, self.nsfw, len(chunk), self.portrait, self.portrait_suffix)
compressed = []
for idx, img_path in enumerate(chunk):
comp = os.path.join(temp_compress_dir, f"comp_{chunk_idx}_{idx}.jpg")
compress_image_for_api(img_path, comp)
compressed.append(comp)
raw_response = ""
max_attempts = self.nsfw_max_rolls + 1 if self.nsfw else 1
for attempt in range(max_attempts):
try:
raw_response = await self.client.analyze_images(compressed, effective_prompt, nsfw=self.nsfw)
if self.nsfw:
if END_MARKER in raw_response:
raw_response = raw_response.replace(END_MARKER, "")
break
else:
if attempt < self.nsfw_max_rolls:
logger.warning(f"⚠️ 第 {chunk_idx+1} 组未检测到暗号,触发重试(Roll) {attempt+1}/{self.nsfw_max_rolls}...")
await asyncio.sleep(1.5)
continue
else:
break
except Exception as e:
if attempt < max_attempts - 1:
await asyncio.sleep(2)
continue
raise e
try:
prompts = parse_batch_response(raw_response, len(chunk))
for i, img_path in enumerate(chunk):
original_name = Path(img_path).name
stem_name = Path(img_path).stem
dst_img = os.path.join(output_dir, original_name)
shutil.copy2(img_path, dst_img)
dst_txt = os.path.join(output_dir, f"{stem_name}.txt")
with open(dst_txt, "w", encoding="utf-8") as f: f.write(prompts[i])
results.append(ProcessResult(filename=original_name, prompt=prompts[i], success=True))
except Exception as e:
for img_path in chunk:
results.append(ProcessResult(filename=Path(img_path).name, prompt="", success=False, error=str(e)))
await asyncio.sleep(0.5)
zip_path_all = f"{output_dir}_all.zip"
zip_path_txt = f"{output_dir}_only_txt.zip"
with zipfile.ZipFile(zip_path_all, "w", zipfile.ZIP_DEFLATED) as zf_all, \
zipfile.ZipFile(zip_path_txt, "w", zipfile.ZIP_DEFLATED) as zf_txt:
for root, _, files in os.walk(output_dir):
for f in sorted(files):
if f.endswith(".zip"): continue
file_path = os.path.join(root, f)
zf_all.write(file_path, f)
if f.endswith(".txt"): zf_txt.write(file_path, f)
shutil.rmtree(temp_compress_dir, ignore_errors=True)
return [zip_path_all, zip_path_txt], results
def process_batch_sync(input_paths, provider=None, api_key=None, base_url=None, model=None,
custom_prompt=None, images_per_request=None, nsfw=False, nsfw_max_rolls=3,
enabled_dims=None, portrait=False, portrait_suffix=""):
clean_old_temp_dirs(config.OUTPUT_DIR)
timestamp = int(time.time())
task_output_dir = os.path.join(config.OUTPUT_DIR, f"batch_{timestamp}")
os.makedirs(task_output_dir, exist_ok=True)
final_image_paths = []
temp_extract_dir = None
if len(input_paths) == 1 and input_paths[0].endswith(".zip"):
temp_extract_dir = tempfile.mkdtemp()
try:
with zipfile.ZipFile(input_paths[0], 'r') as zr:
for fi in zr.infolist():
if fi.filename.startswith('__MACOSX') or fi.filename.startswith('.'): continue
if Path(fi.filename).suffix.lower() in ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif']:
fn = Path(fi.filename).name
ep = os.path.join(temp_extract_dir, fn)
if os.path.exists(ep): ep = os.path.join(temp_extract_dir, f"{int(time.time()*1000)}_{fn}")
with zr.open(fi) as src, open(ep, "wb") as dst: shutil.copyfileobj(src, dst)
final_image_paths.append(ep)
final_image_paths.sort()
except Exception as e:
return None, f"❌ ZIP 解压失败: {e}", []
else:
final_image_paths = input_paths
if not final_image_paths: return None, "❌ 未找到有效图片", []
client = get_ai_client(provider, api_key, base_url, model)
processor = BatchProcessor(
ai_client=client, custom_prompt=custom_prompt, images_per_request=images_per_request,
nsfw=nsfw, nsfw_max_rolls=nsfw_max_rolls, enabled_dims=enabled_dims or DEFAULT_ENABLED_DIMS,
portrait=portrait, portrait_suffix=portrait_suffix
)
loop = asyncio.new_event_loop()
try: zip_paths, results = loop.run_until_complete(processor.process_batch(final_image_paths, task_output_dir))
finally:
loop.close()
if temp_extract_dir and os.path.exists(temp_extract_dir): shutil.rmtree(temp_extract_dir, ignore_errors=True)
gallery_data = []
summary_lines = []
for idx, r in enumerate(results, 1):
saved_path = os.path.join(task_output_dir, r.filename)
if r.success and os.path.exists(saved_path): gallery_data.append((saved_path, f"[{idx}] {r.filename}"))
status = "✅" if r.success else "❌"
content = r.prompt if r.success else f"Error: {r.error}"
summary_lines.append(f"{status} {r.filename}\n{'-'*40}\n{content}\n")
summary = f"\n{'='*60}\n\n".join(summary_lines)
return zip_paths, summary, gallery_data
# ==================== 新增:标签扩写核心逻辑 ====================
def expand_tags_sync(tags, provider=None, api_key=None, base_url=None, model=None,
nsfw=False, nsfw_max_rolls=3, enabled_dims=None, portrait=False, portrait_suffix=""):
if not tags.strip(): return "❌ 请先输入需要扩写的标签或元素"
client = get_ai_client(provider, api_key, base_url, model)
effective_prompt = build_expansion_prompt(enabled_dims or DEFAULT_ENABLED_DIMS, nsfw, portrait, portrait_suffix)
full_prompt = f"{effective_prompt}\n\n【用户输入的待扩写标签/元素】:\n{tags}"
async def _run():
max_attempts = nsfw_max_rolls + 1 if nsfw else 1
raw_response = ""
for attempt in range(max_attempts):
try:
# 传入空图片列表,客户端会当做纯文本请求处理
raw_response = await client.analyze_images([], full_prompt, nsfw=nsfw)
if nsfw:
if END_MARKER in raw_response:
raw_response = raw_response.replace(END_MARKER, "")
break
else:
if attempt < nsfw_max_rolls:
await asyncio.sleep(1.5)
continue
break
except Exception as e:
if attempt < max_attempts - 1:
await asyncio.sleep(2)
continue
return f"❌ 扩写失败: {str(e)}"
# 清理可能残留的图片模板垃圾格式
junk = [r'已确认.*?\n', r'好的.*?\n', r'Understood.*?\n', r'■', r'\[IMAGE 1\]\n?']
for pat in junk: raw_response = re.sub(pat, '', raw_response, flags=re.IGNORECASE)
return raw_response.strip()
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(_run())
finally:
loop.close()