feat: use image concurrency for outfit directories
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user