feat: use image concurrency for outfit directories

This commit is contained in:
2026-06-22 18:10:54 +08:00
parent 00ed41db14
commit e83dd07dc0
7 changed files with 131 additions and 45 deletions
+2 -1
View File
@@ -112,6 +112,7 @@ class _OutfitWorker(QObject):
task, self._prompt, self._output_dir, self._model_config,
quality=self._quality, resolution=self._resolution,
request_interval=self._options.request_interval,
image_concurrency=self._options.concurrency,
image_log=self.log.emit,
)
@@ -337,7 +338,7 @@ class AiOutfitPanel(QWidget):
self._quality.addItems(_QUALITIES)
pairs = [
("并发数", self._concurrency), ("新请求间隔", self._interval),
("图片并发数", self._concurrency), ("新请求间隔", self._interval),
("单任务冷却", self._cooldown), ("失败重试", self._retry_count),
("分辨率", self._resolution), ("JPG 质量", self._quality),
]
+57 -24
View File
@@ -1,7 +1,9 @@
import logging
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from io import BytesIO
from pathlib import Path
@@ -144,6 +146,7 @@ def generate_outfit_image(
resolution="1K",
api_client=None,
request_interval=0.0,
image_concurrency=1,
image_log=None,
):
"""Generate outfit image(s) for one Excel row and return OutfitResult.
@@ -160,7 +163,8 @@ def generate_outfit_image(
return _generate_directory_outfit(
task, prompt_template, output_dir, model_config,
quality=quality, resolution=resolution, api_client=api_client,
request_interval=request_interval, image_log=image_log,
request_interval=request_interval, image_concurrency=image_concurrency,
image_log=image_log,
)
try:
@@ -198,6 +202,7 @@ def _generate_directory_outfit(
resolution,
api_client,
request_interval,
image_concurrency,
image_log,
):
"""Fan one directory row out into per-image generations (docs/11 §4.1/§9.1).
@@ -228,34 +233,43 @@ def _generate_directory_outfit(
subdir_name = os.path.basename(os.path.normpath(directory))
subdir_path = str(Path(output_dir) / safe_product_filename(subdir_name))
prompt = render_prompt(prompt_template, task, resolution=resolution)
client = api_client or ImageApiClient(model_config)
interval = float(request_interval or 0.0)
workers = max(1, int(image_concurrency or 1))
limiter = _RequestStartLimiter(interval)
def generate_one(index, image_path):
output_path = make_outfit_subdir_path(output_dir, subdir_name, image_path.stem)
if output_path.exists():
emit("第 {} 行 第 {}/{} 张已存在,跳过:{}".format(
task.row_index, index, total, image_path.name))
return True, str(output_path), ""
try:
limiter.wait()
client = api_client or ImageApiClient(model_config)
image_bytes = client.generate(prompt, str(image_path), resolution=resolution)
save_jpg_under_limit(image_bytes, output_path, quality=quality)
emit("第 {} 行 第 {}/{} 张完成:{}".format(
task.row_index, index, total, image_path.name))
return True, str(output_path), ""
except Exception as exc: # noqa: BLE001 - record and continue
emit("第 {} 行 第 {}/{} 张失败:{}({})".format(
task.row_index, index, total, image_path.name, exc))
return False, "", "{}:{}".format(image_path.name, exc)
total = len(images)
outputs = []
failures = []
called = False
for index, image_path in enumerate(images, start=1):
output_path = make_outfit_subdir_path(output_dir, subdir_name, image_path.stem)
if output_path.exists():
outputs.append(str(output_path))
emit("第 {} 行 第 {}/{} 张已存在,跳过:{}".format(
task.row_index, index, total, image_path.name))
continue
try:
if called and interval > 0:
time.sleep(interval)
image_bytes = client.generate(prompt, str(image_path), resolution=resolution)
called = True
save_jpg_under_limit(image_bytes, output_path, quality=quality)
outputs.append(str(output_path))
emit("第 {} 行 第 {}/{} 张完成:{}".format(
task.row_index, index, total, image_path.name))
except Exception as exc: # noqa: BLE001 - record and continue
called = True
failures.append("{}:{}".format(image_path.name, exc))
emit("第 {} 行 第 {}/{} 张失败:{}({})".format(
task.row_index, index, total, image_path.name, exc))
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(generate_one, index, image_path)
for index, image_path in enumerate(images, start=1)
]
for future in as_completed(futures):
ok, output, error = future.result()
if ok:
outputs.append(output)
else:
failures.append(error)
if failures:
error = "{} 张中 {} 张失败:{}".format(total, len(failures), ";".join(failures))
@@ -278,6 +292,25 @@ def _coerce_quality(quality):
return QUALITY_BALANCED
class _RequestStartLimiter:
"""Thread-safe minimum interval between AI request starts."""
def __init__(self, interval_seconds):
self.interval_seconds = max(0.0, float(interval_seconds or 0.0))
self._lock = threading.Lock()
self._next_time = 0.0
def wait(self):
if self.interval_seconds <= 0:
return
with self._lock:
now = time.monotonic()
if now < self._next_time:
time.sleep(self._next_time - now)
now = time.monotonic()
self._next_time = now + self.interval_seconds
def _crop_square(img):
width, height = img.size
side = min(width, height)
+12 -11
View File
@@ -19,6 +19,8 @@ LogCallback = Callable[[str], None]
class OutfitBatchOptions:
"""Batch generation controls for AI outfit tasks."""
# Kept as "concurrency" for app_config compatibility. Business meaning is
# now image concurrency inside one directory row; Excel row concurrency is 1.
concurrency: int = 1
request_interval: float = 0.0
task_cooldown: float = 0.0
@@ -60,7 +62,7 @@ class RateLimiter:
class OutfitBatchRunner:
"""Run outfit tasks with concurrency, rate limiting, retries and stop."""
"""Run Excel rows sequentially with retries, heartbeat and gentle stop."""
def __init__(
self,
@@ -80,11 +82,6 @@ class OutfitBatchRunner:
self._sleep = sleep_func or time.sleep
self._time = time_func or time.monotonic
self._stop_event = threading.Event()
self._limiter = RateLimiter(
self.options.request_interval,
now_func=self._time,
sleep_func=self._sleep,
)
def stop(self):
"""Request a gentle stop: no new tasks, in-flight tasks finish."""
@@ -94,11 +91,16 @@ class OutfitBatchRunner:
total = len(self.tasks)
results = []
completed = 0
max_workers = max(1, int(self.options.concurrency or 1))
image_concurrency = max(1, int(self.options.concurrency or 1))
row_workers = 1
self._log("开始 AI 穿搭批量生成:{} 项,并发 {}".format(total, max_workers))
self._log(
"开始 AI 穿搭批量生成:{} 项,Excel 行并发 1,图片并发 {}".format(
total, image_concurrency
)
)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
with ThreadPoolExecutor(max_workers=row_workers) as executor:
task_iter = iter(self.tasks)
futures = {}
submitted_all = False
@@ -106,7 +108,7 @@ class OutfitBatchRunner:
while futures or not submitted_all:
while (
not self._stop_event.is_set()
and len(futures) < max_workers
and len(futures) < row_workers
and not submitted_all
):
try:
@@ -114,7 +116,6 @@ class OutfitBatchRunner:
except StopIteration:
submitted_all = True
break
self._limiter.wait()
future = executor.submit(self._run_one_with_retry, task)
futures[future] = _RunningTask(
task=task,