test(api): add multi-image condition set tests

Cases: two images on img2img, an aligned first image with a different requested size, a batch size above the image count, a request over max_condition_images, and two control inputs. With --log each case also reads the Base log line for the image count and call size the pipeline received; a call sized from the first image returns the requested size with scrambled content.
This commit is contained in:
CalamitousFelicitousness
2026-09-26 18:47:37 +01:00
parent 8cbd55ebe4
commit 9f705b263e
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python
"""
API integration tests for multi-image condition sets.
A pipeline that declares max_condition_images receives an init image list as one
set shared by every prompt, generates at the requested size aligned to the model
multiple, and rejects more images than it declares.
Covers:
- POST /sdapi/v1/img2img: two images, one output at the requested size
- POST /sdapi/v1/img2img: aligned first image with a different requested size
- POST /sdapi/v1/img2img: batch size above the image count, image list not padded
- POST /sdapi/v1/img2img: more images than declared, no output
- POST /sdapi/v1/control: two inputs, one output
Requires a running SD.Next instance with a multi-image model loaded (Qwen-Image 2.1).
With --log pointing at the server's sdnext.log, each case also checks the Base log
line for the number of images and the size the pipeline received.
Usage:
python test/test-condition-images-api.py --url http://127.0.0.1:7860 [--log sdnext.log] [--multiple 32] [--max-images 10] [--steps 4]
"""
import io
import os
import re
import sys
import time
import base64
import argparse
import requests
import urllib3
from PIL import Image
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def encode(image):
buffer = io.BytesIO()
image.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode()
def decode(data):
return Image.open(io.BytesIO(base64.b64decode(data.split(',', 1)[-1])))
def solid(width, height, color):
return Image.new('RGB', (width, height), color)
class ConditionImagesAPITest:
"""Drives condition-set scenarios over the HTTP API."""
def __init__(self, base_url, log_file=None, multiple=32, max_images=10, steps=4):
self.base_url = base_url.rstrip('/')
self.log_file = log_file
self.multiple = multiple
self.max_images = max_images
self.steps = steps
self.timeout = 1800
self.passed = 0
self.failed = 0
self.skipped = 0
def record(self, ok, name, detail=''):
self.passed += 1 if ok else 0
self.failed += 0 if ok else 1
line = f' {"PASS" if ok else "FAIL"}: {name}'
if detail:
line += f' ({detail})'
print(line, flush=True)
def skip(self, name, reason):
self.skipped += 1
print(f' SKIP: {name} ({reason})', flush=True)
def aligned(self, value):
return self.multiple * (value // self.multiple)
def post(self, endpoint, payload):
offset = os.path.getsize(self.log_file) if self.log_file else 0
t0 = time.time()
r = requests.post(f'{self.base_url}{endpoint}', json=payload, timeout=self.timeout, verify=False)
elapsed = time.time() - t0
lines = []
if self.log_file:
time.sleep(1)
with open(self.log_file, encoding='utf8', errors='replace') as f:
f.seek(offset)
lines = f.read().splitlines()
images = []
if r.status_code == 200:
images = [decode(i) for i in (r.json().get('images') or [])]
return r.status_code, images, lines, elapsed
def base_line(self, lines):
"""Image count and call size from the Base log line, None without a log."""
if not self.log_file:
return None
text = ' '.join(lines)
match = re.search(r'Base: pipeline=.*?set=(\{.*?\'parser\')', text)
if match is None:
return {'images': 0, 'size': None}
seg = match.group(1)
width = re.search(r"'width': (\d+)", seg)
height = re.search(r"'height': (\d+)", seg)
return {'images': seg.count('<PIL.Image.Image'), 'size': (int(width.group(1)), int(height.group(1))) if width and height else None}
def img2img(self, init_images, width, height, batch_size=1):
payload = {
'prompt': 'Put the object from Image 2 next to the subject of Image 1.',
'init_images': [encode(i) for i in init_images],
'width': width,
'height': height,
'batch_size': batch_size,
'steps': self.steps,
'seed': 42,
'denoising_strength': 1.0,
'save_images': False,
'send_images': True,
}
return self.post('/sdapi/v1/img2img', payload)
def check_generation(self, name, result, outputs, size, base_images):
status, images, lines, elapsed = result
sizes = [i.size for i in images]
self.record(status == 200 and len(images) == outputs, f'{name}: {outputs} output(s)', f'http={status} outputs={len(images)} time={elapsed:.1f}s')
if images:
self.record(all(s == size for s in sizes), f'{name}: output size {size[0]}x{size[1]}', f'sizes={sizes}')
base = self.base_line(lines)
if base is not None:
self.record(base['images'] == base_images and base['size'] == size, f'{name}: pipeline received {base_images} image(s) at {size[0]}x{size[1]}', f'log images={base["images"]} size={base["size"]}')
errors = [line for line in lines if ' ERROR ' in line]
self.record(not errors, f'{name}: no errors logged', errors[0][:160] if errors else '')
def test_two_images(self):
print('=== img2img: two images, unaligned request ===', flush=True)
result = self.img2img([solid(1336, 744, (200, 60, 40)), solid(1200, 800, (40, 180, 60))], 1336, 744)
self.check_generation('two images', result, 1, (self.aligned(1336), self.aligned(744)), 2)
def test_aligned_first(self):
print('=== img2img: aligned first image, different requested size ===', flush=True)
image = solid(1024, 1536, (90, 90, 200))
self.check_generation('square request', self.img2img([image], 1024, 1024), 1, (1024, 1024), 1)
self.check_generation('transposed request', self.img2img([image], 1536, 1024), 1, (1536, 1024), 1)
def test_batch(self):
print('=== img2img: batch size above the image count ===', flush=True)
result = self.img2img([solid(1344, 768, (200, 60, 40))], 1344, 768, batch_size=2)
self.check_generation('batch 2', result, 2, (1344, 768), 1)
def test_over_cap(self):
print('=== img2img: more images than declared ===', flush=True)
status, images, lines, elapsed = self.img2img([solid(256, 256, (10 * i, 40, 90)) for i in range(self.max_images + 1)], 1024, 1024)
self.record(len(images) == 0, 'over cap: no output', f'http={status} outputs={len(images)} time={elapsed:.1f}s')
if self.log_file:
rejected = [line for line in lines if 'Mismatch:' in line and f'images={self.max_images + 1} max={self.max_images}' in line]
base = self.base_line(lines)
self.record(bool(rejected) and base['images'] == 0, 'over cap: rejected before the pipeline runs', rejected[0].strip()[:160] if rejected else 'no Mismatch line')
def test_control(self):
print('=== control: two inputs ===', flush=True)
payload = {
'prompt': 'Put the object from Image 2 next to the subject of Image 1.',
'inputs': [encode(solid(1344, 768, (200, 60, 40))), encode(solid(1200, 800, (40, 180, 60)))],
'input_type': 1,
'width_before': 1344,
'height_before': 768,
'steps': self.steps,
'seed': 42,
'denoising_strength': 1.0,
'save_images': False,
}
result = self.post('/sdapi/v1/control', payload)
if result[0] == 422:
self.skip('control two inputs', 'the control route rejects image inputs')
return
self.check_generation('control two inputs', result, 1, (1344, 768), 2)
def run(self):
model = requests.get(f'{self.base_url}/sdapi/v1/options', timeout=60, verify=False).json().get('sd_model_checkpoint')
print(f'model: {model}', flush=True)
self.test_two_images()
self.test_aligned_first()
self.test_batch()
self.test_over_cap()
self.test_control()
print('=== results ===', flush=True)
print(f' passed={self.passed} failed={self.failed} skipped={self.skipped}', flush=True)
return self.failed == 0
def main():
ap = argparse.ArgumentParser(description='Multi-image condition set API tests')
ap.add_argument('--url', default='http://127.0.0.1:7860', help='SD.Next base URL')
ap.add_argument('--log', default=None, help='server sdnext.log, enables checks on what the pipeline received')
ap.add_argument('--multiple', type=int, default=32, help='size multiple of the loaded model')
ap.add_argument('--max-images', type=int, default=10, help='max_condition_images of the loaded model')
ap.add_argument('--steps', type=int, default=4)
args = ap.parse_args()
try:
requests.get(f'{args.url.rstrip("/")}/sdapi/v1/options', timeout=10, verify=False)
except Exception as e:
print(f'cannot reach SD.Next at {args.url}: {e}', flush=True)
return 2
ok = ConditionImagesAPITest(args.url, log_file=args.log, multiple=args.multiple, max_images=args.max_images, steps=args.steps).run()
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())