mirror of
https://github.com/vladmandic/automatic
synced 2026-08-26 15:16:01 +02:00
f9ab0bf04d
Surface YoloRestorer.restore() as a standalone operation: a Detailer
postprocessing script in the Process tab and a thin /sdapi/v1/detail
endpoint, neither requiring a base generation pass.
- modules/postprocess/yolo.py: YoloRestorer.make_processing() builds the
synthetic Img2Img processing object both entry points feed to restore(),
resolving the seed so the inpaint passes are reproducible
- modules/api/process.py: post_detail handler exposes the full detailer
parameter set and returns the detailed image plus optional annotations
as base64
- scripts/postprocessing_detailer.py: reuses shared.yolo.ui('extras') and
runs through make_processing()
- modules/postprocessing.py: run_extras takes a per-script script_args
dict, also letting the extras API drive other scripts such as Remove
background; omitting it leaves existing callers unchanged
- modules/api/models.py: ReqDetail / ResDetail
- modules/processing_info.py: guard create_infotext's Image/Hires CFG
reporting against an unset (None) cfg_image, matching the is-not-None
checks the other cfg_image readers use; the detailer inpaint pass runs
with it unset
- test/test-detailer-api.py: covers both paths; effect tests measure the
diff inside the detected region with extreme isolated parameter values,
and the suite disables model quantization for the run and restores the
original settings afterward
1048 lines
48 KiB
Python
1048 lines
48 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
API tests for YOLO Detailer endpoints.
|
|
|
|
Tests:
|
|
- GET /sdapi/v1/detailers — model enumeration
|
|
- POST /sdapi/v1/detect — object detection on test images
|
|
- POST /sdapi/v1/txt2img — generation with detailer enabled
|
|
|
|
Requires a running SD.Next instance with a model loaded.
|
|
|
|
Usage:
|
|
python test/test-detailer-api.py [--url URL] [--image PATH]
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
import time
|
|
import json
|
|
import base64
|
|
import argparse
|
|
import requests
|
|
import urllib3
|
|
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
# Reference model cover images with faces (best for detailer testing)
|
|
FACE_TEST_IMAGES = [
|
|
'models/Reference/ponyRealism_V23.jpg', # realistic woman, clear face
|
|
'models/Reference/HiDream-ai--HiDream-I1-Fast.jpg', # realistic man, clear face + text
|
|
'models/Reference/stabilityai--stable-diffusion-xl-base-1.0.jpg', # realistic woman portrait
|
|
'models/Reference/CalamitousFelicitousness--Anima-Preview-3-sdnext-diffusers.jpg', # anime face (non-realistic test)
|
|
]
|
|
|
|
# Fallback images (no guaranteed faces)
|
|
FALLBACK_IMAGES = [
|
|
'html/sdnext-robot-2k.jpg',
|
|
'ui/assets/favicon.png',
|
|
]
|
|
|
|
|
|
class DetailerAPITest:
|
|
"""Test harness for YOLO Detailer API endpoints."""
|
|
|
|
def __init__(self, base_url, image_path=None, timeout=300, model_query=None):
|
|
self.base_url = base_url.rstrip('/')
|
|
self.test_images = {} # name -> base64
|
|
self.timeout = timeout
|
|
self.model_query = model_query or 'anima base' # checkpoint to load for the run (substring match)
|
|
self.results = {
|
|
'enumerate': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
'detect': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
'generate': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
'detailer_params': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
'detail_endpoint': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
'extras_script_args': {'passed': 0, 'failed': 0, 'skipped': 0, 'tests': []},
|
|
}
|
|
self._category = 'enumerate'
|
|
self._critical_error = None
|
|
self.face_models = [] # picked in run_all; detectors tried to locate the region for effect-diff crops
|
|
self._load_images(image_path)
|
|
|
|
def _encode_image(self, path):
|
|
from PIL import Image
|
|
image = Image.open(path)
|
|
if image.mode == 'RGBA':
|
|
image = image.convert('RGB')
|
|
buf = io.BytesIO()
|
|
image.save(buf, 'JPEG')
|
|
return base64.b64encode(buf.getvalue()).decode(), image.size
|
|
|
|
def _load_images(self, image_path=None):
|
|
if image_path and os.path.exists(image_path):
|
|
b64, size = self._encode_image(image_path)
|
|
name = os.path.basename(image_path)
|
|
self.test_images[name] = b64
|
|
print(f" Test image: {image_path} ({size})")
|
|
return
|
|
|
|
# Load all available face test images
|
|
for p in FACE_TEST_IMAGES:
|
|
if os.path.exists(p):
|
|
b64, size = self._encode_image(p)
|
|
name = os.path.basename(p)
|
|
self.test_images[name] = b64
|
|
print(f" Loaded: {name} ({size[0]}x{size[1]})")
|
|
|
|
# Fallback if no face images found
|
|
if not self.test_images:
|
|
for p in FALLBACK_IMAGES:
|
|
if os.path.exists(p):
|
|
b64, size = self._encode_image(p)
|
|
name = os.path.basename(p)
|
|
self.test_images[name] = b64
|
|
print(f" Fallback: {name} ({size[0]}x{size[1]})")
|
|
break
|
|
|
|
if not self.test_images:
|
|
print(" WARNING: No test images found, detect tests will be skipped")
|
|
|
|
@property
|
|
def image_b64(self):
|
|
"""Return the first available test image for backwards compat."""
|
|
if self.test_images:
|
|
return next(iter(self.test_images.values()))
|
|
return None
|
|
|
|
def _get(self, endpoint):
|
|
try:
|
|
r = requests.get(f'{self.base_url}{endpoint}', timeout=self.timeout, verify=False)
|
|
if r.status_code != 200:
|
|
return {'error': r.status_code, 'reason': r.reason}
|
|
return r.json()
|
|
except requests.exceptions.ConnectionError:
|
|
return {'error': 'connection_refused', 'reason': 'Server not running'}
|
|
except Exception as e:
|
|
return {'error': 'exception', 'reason': str(e)}
|
|
|
|
def _post(self, endpoint, data):
|
|
try:
|
|
r = requests.post(f'{self.base_url}{endpoint}', json=data, timeout=self.timeout, verify=False)
|
|
if r.status_code != 200:
|
|
return {'error': r.status_code, 'reason': r.reason}
|
|
return r.json()
|
|
except requests.exceptions.ConnectionError:
|
|
return {'error': 'connection_refused', 'reason': 'Server not running'}
|
|
except Exception as e:
|
|
return {'error': 'exception', 'reason': str(e)}
|
|
|
|
def record(self, passed, name, detail=''):
|
|
status = 'PASS' if passed else 'FAIL'
|
|
self.results[self._category]['passed' if passed else 'failed'] += 1
|
|
self.results[self._category]['tests'].append((status, name))
|
|
msg = f' {status}: {name}'
|
|
if detail:
|
|
msg += f' ({detail})'
|
|
print(msg)
|
|
|
|
def skip(self, name, reason):
|
|
self.results[self._category]['skipped'] += 1
|
|
self.results[self._category]['tests'].append(('SKIP', name))
|
|
print(f' SKIP: {name} ({reason})')
|
|
|
|
# =========================================================================
|
|
# Tests: Model Enumeration
|
|
# =========================================================================
|
|
|
|
def test_detailers_list(self):
|
|
"""GET /sdapi/v1/detailers returns a list of available models."""
|
|
self._category = 'enumerate'
|
|
print("\n--- Detailer Model Enumeration ---")
|
|
|
|
data = self._get('/sdapi/v1/detailers')
|
|
if 'error' in data:
|
|
self.record(False, 'detailers_list', f"error: {data}")
|
|
self._critical_error = f"Server error: {data}"
|
|
return []
|
|
|
|
if not isinstance(data, list):
|
|
self.record(False, 'detailers_list', f"expected list, got {type(data).__name__}")
|
|
return []
|
|
|
|
self.record(True, 'detailers_list', f"{len(data)} models found")
|
|
|
|
# Verify each entry has expected fields
|
|
if len(data) > 0:
|
|
sample = data[0]
|
|
has_name = 'name' in sample
|
|
self.record(has_name, 'detailer_entry_has_name', f"sample: {sample}")
|
|
if not has_name:
|
|
self.record(False, 'detailer_entry_schema', "missing 'name' field")
|
|
|
|
return data
|
|
|
|
# =========================================================================
|
|
# Tests: Detection
|
|
# =========================================================================
|
|
|
|
def _validate_detect_response(self, data, label):
|
|
"""Validate detection response schema and return detection count."""
|
|
expected_keys = ['classes', 'labels', 'boxes', 'scores']
|
|
for key in expected_keys:
|
|
if key not in data:
|
|
self.record(False, f'{label}_schema_{key}', f"missing '{key}'")
|
|
return -1
|
|
|
|
# All arrays should have the same length
|
|
lengths = [len(data[key]) for key in expected_keys]
|
|
all_same = len(set(lengths)) <= 1
|
|
if not all_same:
|
|
self.record(False, f'{label}_array_lengths', f"mismatched: {dict(zip(expected_keys, lengths))}")
|
|
return -1
|
|
|
|
n = lengths[0]
|
|
|
|
if n > 0:
|
|
# Scores should be in [0, 1]
|
|
scores_valid = all(0 <= s <= 1 for s in data['scores'])
|
|
if not scores_valid:
|
|
self.record(False, f'{label}_scores_range', f"scores: {data['scores']}")
|
|
|
|
# Boxes should be lists of 4 numbers
|
|
boxes_valid = all(isinstance(b, list) and len(b) == 4 for b in data['boxes'])
|
|
if not boxes_valid:
|
|
self.record(False, f'{label}_boxes_format', "bad box format")
|
|
|
|
return n
|
|
|
|
# Face detection models to try (in priority order)
|
|
FACE_MODELS = ['face-yolo8n', 'face-yolo8m', 'anzhc-face-1024-seg-8n']
|
|
|
|
def _pick_face_model(self, available_models):
|
|
"""Pick the best face detection model from available ones."""
|
|
available_names = [m.get('name', '') for m in available_models] if available_models else []
|
|
for model in self.FACE_MODELS:
|
|
if model in available_names:
|
|
return model
|
|
return '' # fall back to server default
|
|
|
|
# Detectors to try when locating the edited region, most-general first. The seg model is listed
|
|
# ahead of the realistic yolo8n/8m so it also fires on stylized (e.g. anime) generated faces; it
|
|
# is also what the default detailer uses, so its box matches the region that was actually edited.
|
|
REGION_MODELS = ['anzhc-face-1024-seg-8n', 'face-yolo8m', 'face-yolo8n', 'anzhc-head-seg-8n']
|
|
|
|
def _pick_region_models(self, available_models):
|
|
"""Ordered list of available face/head detectors to try when locating the edited region."""
|
|
names = [m.get('name', '') for m in (available_models or [])]
|
|
ordered = [m for m in self.REGION_MODELS if m in names]
|
|
ordered += [n for n in names if ('face' in n.lower() or 'head' in n.lower()) and n not in ordered]
|
|
return ordered or [''] # '' = server default
|
|
|
|
def test_detect_all_images(self, available_models=None):
|
|
"""POST /sdapi/v1/detect on each loaded test image with a face model."""
|
|
self._category = 'detect'
|
|
print("\n--- Detection Tests (per-image) ---")
|
|
|
|
if not self.test_images:
|
|
self.skip('detect_all', 'no test images')
|
|
return
|
|
|
|
if self._critical_error:
|
|
self.skip('detect_all', self._critical_error)
|
|
return
|
|
|
|
face_model = self._pick_face_model(available_models)
|
|
if face_model:
|
|
print(f" Using face model: {face_model}")
|
|
else:
|
|
print(" No face model available, using server default")
|
|
|
|
total_detections = 0
|
|
any_face_found = False
|
|
|
|
for img_name, img_b64 in self.test_images.items():
|
|
short = img_name.replace('.jpg', '')[:40]
|
|
data = self._post('/sdapi/v1/detect', {'image': img_b64, 'model': face_model})
|
|
|
|
if 'error' in data:
|
|
self.record(False, f'detect_{short}', f"error: {data}")
|
|
continue
|
|
|
|
n = self._validate_detect_response(data, f'detect_{short}')
|
|
if n < 0:
|
|
continue
|
|
|
|
labels = data.get('labels', [])
|
|
scores = data.get('scores', [])
|
|
detail_parts = [f"{n} detections"]
|
|
if labels:
|
|
detail_parts.append(f"labels={labels}")
|
|
if scores:
|
|
detail_parts.append(f"top_score={max(scores):.3f}")
|
|
|
|
self.record(True, f'detect_{short}', ', '.join(detail_parts))
|
|
total_detections += n
|
|
if n > 0:
|
|
any_face_found = True
|
|
|
|
self.record(any_face_found, 'detect_found_faces',
|
|
f"{total_detections} total detections across {len(self.test_images)} images")
|
|
|
|
def test_detect_with_model(self, model_name):
|
|
"""POST /sdapi/v1/detect with a specific model on all images."""
|
|
if not self.test_images:
|
|
self.skip(f'detect_model_{model_name}', 'no test images')
|
|
return
|
|
|
|
total = 0
|
|
for _img_name, img_b64 in self.test_images.items():
|
|
data = self._post('/sdapi/v1/detect', {'image': img_b64, 'model': model_name})
|
|
if 'error' not in data:
|
|
total += len(data.get('scores', []))
|
|
|
|
self.record(True, f'detect_model_{model_name}', f"{total} detections across {len(self.test_images)} images")
|
|
|
|
# =========================================================================
|
|
# Tests: Generation with Detailer
|
|
# =========================================================================
|
|
|
|
def test_txt2img_with_detailer(self):
|
|
"""POST /sdapi/v1/txt2img with detailer_enabled=True."""
|
|
self._category = 'generate'
|
|
print("\n--- Generation with Detailer ---")
|
|
|
|
if self._critical_error:
|
|
self.skip('txt2img_detailer', self._critical_error)
|
|
return
|
|
|
|
payload = {
|
|
'prompt': 'a photo of a person, face, portrait',
|
|
'negative_prompt': '',
|
|
'steps': 10,
|
|
'width': 512,
|
|
'height': 512,
|
|
'seed': 42,
|
|
'save_images': False,
|
|
'send_images': True,
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
'detailer_max': 3,
|
|
}
|
|
|
|
t0 = time.time()
|
|
# Detailer generation is multi-pass (generate + detect + inpaint per region), use longer timeout
|
|
try:
|
|
r = requests.post(f'{self.base_url}/sdapi/v1/txt2img', json=payload, timeout=600, verify=False)
|
|
if r.status_code != 200:
|
|
data = {'error': r.status_code, 'reason': r.reason}
|
|
else:
|
|
data = r.json()
|
|
except requests.exceptions.ConnectionError as e:
|
|
self.record(False, 'txt2img_detailer', f"connection error (is a model loaded?): {e}")
|
|
return
|
|
except requests.exceptions.ReadTimeout:
|
|
self.record(False, 'txt2img_detailer', 'timeout after 600s')
|
|
return
|
|
t1 = time.time()
|
|
|
|
if 'error' in data:
|
|
self.record(False, 'txt2img_detailer', f"error: {data} (ensure a model is loaded)")
|
|
return
|
|
|
|
# Should have images
|
|
has_images = 'images' in data and len(data['images']) > 0
|
|
self.record(has_images, 'txt2img_detailer_has_images', f"time={t1 - t0:.1f}s")
|
|
|
|
if has_images:
|
|
# Decode and verify image
|
|
from PIL import Image
|
|
img_data = data['images'][0].split(',', 1)[0]
|
|
img = Image.open(io.BytesIO(base64.b64decode(img_data)))
|
|
self.record(True, 'txt2img_detailer_image_valid', f"size={img.size}")
|
|
|
|
# Check info field for detailer metadata
|
|
if 'info' in data:
|
|
info = data['info'] if isinstance(data['info'], str) else json.dumps(data['info'])
|
|
has_detailer_info = 'detailer' in info.lower() or 'Detailer' in info
|
|
self.record(has_detailer_info, 'txt2img_detailer_metadata',
|
|
'detailer info found in metadata' if has_detailer_info else 'no detailer metadata (detection may have found nothing)')
|
|
|
|
def test_txt2img_without_detailer(self):
|
|
"""POST /sdapi/v1/txt2img baseline without detailer (sanity check)."""
|
|
if self._critical_error:
|
|
self.skip('txt2img_baseline', self._critical_error)
|
|
return
|
|
|
|
payload = {
|
|
'prompt': 'a simple landscape',
|
|
'steps': 5,
|
|
'width': 512,
|
|
'height': 512,
|
|
'seed': 42,
|
|
'save_images': False,
|
|
'send_images': True,
|
|
}
|
|
|
|
data = self._post('/sdapi/v1/txt2img', payload)
|
|
if 'error' in data:
|
|
self.record(False, 'txt2img_baseline', f"error: {data}")
|
|
return
|
|
|
|
has_images = 'images' in data and len(data['images']) > 0
|
|
self.record(has_images, 'txt2img_baseline', 'generation works without detailer')
|
|
|
|
# =========================================================================
|
|
# Tests: Per-Request Detailer Param Validation
|
|
# =========================================================================
|
|
|
|
def _txt2img(self, extra_params=None):
|
|
"""Helper: generate a portrait with optional param overrides."""
|
|
payload = {
|
|
'prompt': 'a photo of a person, face, portrait',
|
|
'steps': 10,
|
|
'width': 512,
|
|
'height': 512,
|
|
'seed': 42,
|
|
'save_images': False,
|
|
'send_images': True,
|
|
}
|
|
if extra_params:
|
|
payload.update(extra_params)
|
|
try:
|
|
r = requests.post(f'{self.base_url}/sdapi/v1/txt2img', json=payload, timeout=600, verify=False)
|
|
if r.status_code != 200:
|
|
return {'error': r.status_code, 'reason': r.reason}
|
|
return r.json()
|
|
except requests.exceptions.ConnectionError as e:
|
|
return {'error': 'connection_refused', 'reason': str(e)}
|
|
except requests.exceptions.ReadTimeout:
|
|
return {'error': 'timeout', 'reason': 'timeout after 600s'}
|
|
|
|
def _decode_image(self, data):
|
|
"""Decode first image from generation response into numpy array."""
|
|
import numpy as np
|
|
from PIL import Image
|
|
if 'images' not in data or len(data['images']) == 0:
|
|
return None
|
|
img_data = data['images'][0].split(',', 1)[0]
|
|
img = Image.open(io.BytesIO(base64.b64decode(img_data))).convert('RGB')
|
|
return np.array(img, dtype=np.float32)
|
|
|
|
def _pixel_diff(self, arr_a, arr_b):
|
|
"""Mean absolute pixel difference between two images."""
|
|
import numpy as np
|
|
if arr_a is None or arr_b is None or arr_a.shape != arr_b.shape:
|
|
return -1.0
|
|
return float(np.abs(arr_a - arr_b).mean())
|
|
|
|
def _detect_box(self, img_b64, models=None, pad=0.1):
|
|
"""Largest detection box (x1,y1,x2,y2) from /sdapi/v1/detect, trying each name in `models`
|
|
until one detects something (different detectors fire on realistic vs stylized faces). Padded
|
|
by `pad` of box size per side. Returns None when nothing is found so callers fall back to a
|
|
whole-frame diff."""
|
|
for model in (models or ['']):
|
|
data = self._post('/sdapi/v1/detect', {'image': img_b64, 'model': model})
|
|
boxes = data.get('boxes', []) if 'error' not in data else []
|
|
if not boxes:
|
|
continue
|
|
box = max(boxes, key=lambda b: max(0, b[2] - b[0]) * max(0, b[3] - b[1]))
|
|
x1, y1, x2, y2 = (float(v) for v in box)
|
|
dx, dy = (x2 - x1) * pad, (y2 - y1) * pad
|
|
return (int(x1 - dx), int(y1 - dy), int(x2 + dx), int(y2 + dy))
|
|
return None
|
|
|
|
def _region_diff(self, arr_a, arr_b, box):
|
|
"""Mean absolute pixel difference within box=(x1,y1,x2,y2), clamped to image bounds. The
|
|
detailer only edits the detected region, so cropping to it keeps a real but localized change
|
|
from being averaged away by the unchanged majority of the frame. Whole-frame when box is None."""
|
|
import numpy as np
|
|
if arr_a is None or arr_b is None or arr_a.shape != arr_b.shape:
|
|
return -1.0
|
|
if box is None:
|
|
return float(np.abs(arr_a - arr_b).mean())
|
|
h, w = arr_a.shape[:2]
|
|
x1 = max(0, min(int(box[0]), w - 1))
|
|
y1 = max(0, min(int(box[1]), h - 1))
|
|
x2 = max(x1 + 1, min(int(box[2]), w))
|
|
y2 = max(y1 + 1, min(int(box[3]), h))
|
|
return float(np.abs(arr_a[y1:y2, x1:x2] - arr_b[y1:y2, x1:x2]).mean())
|
|
|
|
def _get_info(self, data):
|
|
"""Extract info string from generation response."""
|
|
if 'info' not in data:
|
|
return ''
|
|
info = data['info']
|
|
return info if isinstance(info, str) else json.dumps(info)
|
|
|
|
def run_detailer_param_tests(self, available_models=None):
|
|
"""Verify per-request detailer params change the output."""
|
|
self._category = 'detailer_params'
|
|
print("\n--- Per-Request Detailer Param Validation ---")
|
|
|
|
if self._critical_error:
|
|
self.skip('detailer_params_all', self._critical_error)
|
|
return
|
|
|
|
# Generate baseline WITHOUT detailer (same seed/prompt as detailer tests)
|
|
print(" Generating baseline (no detailer)...")
|
|
baseline_data = self._txt2img()
|
|
if 'error' in baseline_data:
|
|
self.record(False, 'detailer_baseline', f"error: {baseline_data}")
|
|
return
|
|
baseline = self._decode_image(baseline_data)
|
|
if baseline is None:
|
|
self.record(False, 'detailer_baseline', 'no image')
|
|
return
|
|
self.record(True, 'detailer_baseline')
|
|
|
|
# The detailer only repaints the detected face. Variation tests below compare two detailed
|
|
# outputs, so measure their diff inside that box; whole-frame averaging buries the signal
|
|
# under the unchanged ~85% of the image. All variants share the seed=42 base, so one detect
|
|
# on the baseline locates the region for every comparison.
|
|
box = self._detect_box(baseline_data['images'][0], self.face_models) if baseline_data.get('images') else None
|
|
print(f" Detailer region box={box}" if box else " No face box detected; effect diffs fall back to whole-frame")
|
|
|
|
# Generate WITH detailer enabled (default params)
|
|
print(" Generating with detailer (defaults)...")
|
|
detailer_default_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
})
|
|
if 'error' in detailer_default_data:
|
|
self.record(False, 'detailer_default', f"error: {detailer_default_data}")
|
|
return
|
|
detailer_default = self._decode_image(detailer_default_data)
|
|
|
|
# Detailer ON vs OFF should produce different images (if a face was detected)
|
|
diff_on_off = self._pixel_diff(baseline, detailer_default)
|
|
self.record(diff_on_off > 0.5, 'detailer_on_vs_off',
|
|
f"mean_diff={diff_on_off:.2f}" if diff_on_off > 0.5
|
|
else f"identical (diff={diff_on_off:.4f}) — no face detected?")
|
|
|
|
# -- Strength variation (extreme: 0.3 vs 0.9) --
|
|
print(" Testing strength variation (0.3 vs 0.9)...")
|
|
strong_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.9,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
})
|
|
if 'error' not in strong_data:
|
|
strong = self._decode_image(strong_data)
|
|
diff_strong = self._region_diff(detailer_default, strong, box)
|
|
self.record(diff_strong > 0.5, 'detailer_strength_effect',
|
|
f"strength 0.3 vs 0.9: region diff={diff_strong:.2f}")
|
|
|
|
# -- Steps variation (extreme: 1 vs 20 @ strength 0.7) --
|
|
# At a high denoise a single step can't resolve the region while 20 can, so this is a clear
|
|
# yes/no on whether step count drives the result. Both runs share strength 0.7 to isolate steps.
|
|
print(" Testing steps variation (1 vs 20 @ strength 0.7)...")
|
|
few_steps_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.7,
|
|
'detailer_steps': 1,
|
|
'detailer_conf': 0.3,
|
|
})
|
|
more_steps_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.7,
|
|
'detailer_steps': 20,
|
|
'detailer_conf': 0.3,
|
|
})
|
|
if 'error' not in few_steps_data and 'error' not in more_steps_data:
|
|
few_steps = self._decode_image(few_steps_data)
|
|
more_steps = self._decode_image(more_steps_data)
|
|
diff_steps = self._region_diff(few_steps, more_steps, box)
|
|
self.record(diff_steps > 0.5, 'detailer_steps_effect',
|
|
f"steps 1 vs 20 @ strength 0.7: region diff={diff_steps:.2f}")
|
|
|
|
# -- Resolution variation (extreme: 1024 vs 256) --
|
|
print(" Testing resolution variation (1024 vs 256)...")
|
|
hires_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
'detailer_resolution': 256,
|
|
})
|
|
if 'error' not in hires_data:
|
|
hires = self._decode_image(hires_data)
|
|
diff_res = self._region_diff(detailer_default, hires, box)
|
|
self.record(diff_res > 0.5, 'detailer_resolution_effect',
|
|
f"resolution 1024 vs 256: region diff={diff_res:.2f}")
|
|
|
|
# -- Segmentation mode --
|
|
# Segmentation requires a -seg model (e.g. anzhc-face-1024-seg-8n).
|
|
# Detection-only models (face-yolo8n) don't produce masks, so the flag has no effect.
|
|
seg_models = [m.get('name', '') for m in (available_models or [])
|
|
if 'seg' in m.get('name', '').lower() and 'face' in m.get('name', '').lower()]
|
|
if seg_models:
|
|
seg_model = seg_models[0]
|
|
print(f" Testing segmentation mode (model={seg_model})...")
|
|
# bbox baseline with the seg model
|
|
seg_bbox_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
'detailer_segmentation': False,
|
|
'detailer_models': [seg_model],
|
|
})
|
|
seg_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.3,
|
|
'detailer_segmentation': True,
|
|
'detailer_models': [seg_model],
|
|
})
|
|
if 'error' not in seg_data and 'error' not in seg_bbox_data:
|
|
seg_bbox = self._decode_image(seg_bbox_data)
|
|
seg_mask = self._decode_image(seg_data)
|
|
diff_seg = self._region_diff(seg_bbox, seg_mask, box)
|
|
self.record(diff_seg > 0.5, 'detailer_segmentation_effect',
|
|
f"bbox vs seg mask ({seg_model}): region diff={diff_seg:.2f}")
|
|
else:
|
|
err = seg_data if 'error' in seg_data else seg_bbox_data
|
|
self.record(False, 'detailer_segmentation_effect', f"error: {err}")
|
|
else:
|
|
print(" Testing segmentation mode...")
|
|
seg_data = {'error': 'skipped'}
|
|
self.skip('detailer_segmentation_effect', 'no face-seg model available')
|
|
|
|
# -- Confidence threshold --
|
|
print(" Testing confidence threshold...")
|
|
high_conf_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.3,
|
|
'detailer_steps': 5,
|
|
'detailer_conf': 0.95,
|
|
})
|
|
if 'error' not in high_conf_data:
|
|
high_conf = self._decode_image(high_conf_data)
|
|
diff_conf = self._pixel_diff(baseline, high_conf)
|
|
# High confidence may reject detections, making output closer to baseline
|
|
self.record(True, 'detailer_conf_effect',
|
|
f"conf=0.95 vs baseline: diff={diff_conf:.2f} "
|
|
f"(low diff = detections filtered out, high diff = still detected)")
|
|
|
|
# -- Custom detailer prompt (extreme: two divergent prompts @ strength 0.7) --
|
|
# Maximally different prompts at a high denoise should paint visibly different faces, so this
|
|
# checks the detailer prompt reaches the inpaint pass at all. Both runs share strength/steps.
|
|
print(" Testing detailer prompt override (divergent prompts @ strength 0.7)...")
|
|
prompt_a_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.7,
|
|
'detailer_steps': 10,
|
|
'detailer_conf': 0.3,
|
|
'detailer_prompt': 'a photo of an elderly bearded man',
|
|
})
|
|
prompt_b_data = self._txt2img({
|
|
'detailer_enabled': True,
|
|
'detailer_strength': 0.7,
|
|
'detailer_steps': 10,
|
|
'detailer_conf': 0.3,
|
|
'detailer_prompt': 'a photo of a young woman with bright blue hair',
|
|
})
|
|
if 'error' not in prompt_a_data and 'error' not in prompt_b_data:
|
|
prompt_a = self._decode_image(prompt_a_data)
|
|
prompt_b = self._decode_image(prompt_b_data)
|
|
diff_prompt = self._region_diff(prompt_a, prompt_b, box)
|
|
self.record(diff_prompt > 0.5, 'detailer_prompt_effect',
|
|
f"divergent prompts @ strength 0.7: region diff={diff_prompt:.2f}")
|
|
|
|
# -- Metadata verification across params --
|
|
for test_data, label in [
|
|
(detailer_default_data, 'detailer_default'),
|
|
(strong_data if 'error' not in strong_data else None, 'detailer_strong'),
|
|
(more_steps_data if 'error' not in more_steps_data else None, 'detailer_more_steps'),
|
|
(seg_data if 'error' not in seg_data else None, 'detailer_segmentation'),
|
|
]:
|
|
if test_data is None:
|
|
continue
|
|
info = self._get_info(test_data)
|
|
has_meta = 'detailer' in info.lower() or 'Detailer' in info
|
|
self.record(has_meta, f'{label}_metadata',
|
|
'detailer info in metadata' if has_meta else 'no detailer metadata')
|
|
|
|
# -- Param isolation: generate without detailer after all detailer runs --
|
|
print(" Testing param isolation...")
|
|
after_data = self._txt2img()
|
|
if 'error' not in after_data:
|
|
after = self._decode_image(after_data)
|
|
leak_diff = self._pixel_diff(baseline, after)
|
|
self.record(leak_diff < 0.5, 'detailer_param_isolation',
|
|
f"post-detailer baseline diff={leak_diff:.4f}" if leak_diff < 0.5
|
|
else f"LEAK: baseline changed (diff={leak_diff:.2f})")
|
|
|
|
# =========================================================================
|
|
# Tests: /sdapi/v1/detail standalone endpoint
|
|
# =========================================================================
|
|
|
|
def _detail(self, **kwargs):
|
|
"""Helper: POST /sdapi/v1/detail with default face image and override kwargs."""
|
|
if not self.image_b64:
|
|
return {'error': 'no_test_image'}
|
|
payload = {'image': self.image_b64}
|
|
payload.update(kwargs)
|
|
try:
|
|
r = requests.post(f'{self.base_url}/sdapi/v1/detail', json=payload, timeout=self.timeout, verify=False)
|
|
if r.status_code != 200:
|
|
return {'error': r.status_code, 'reason': r.reason}
|
|
return r.json()
|
|
except requests.exceptions.ConnectionError as e:
|
|
return {'error': 'connection_refused', 'reason': str(e)}
|
|
except requests.exceptions.ReadTimeout:
|
|
return {'error': 'timeout'}
|
|
|
|
def _decode_b64_image(self, b64_str):
|
|
"""Decode a base64 image string into a numpy float32 RGB array."""
|
|
import numpy as np
|
|
from PIL import Image
|
|
try:
|
|
img_data = b64_str.split(',', 1)[0] if ',' in b64_str else b64_str
|
|
img = Image.open(io.BytesIO(base64.b64decode(img_data))).convert('RGB')
|
|
return np.array(img, dtype=np.float32)
|
|
except Exception:
|
|
return None
|
|
|
|
def test_detail_endpoint_basic(self):
|
|
"""POST /sdapi/v1/detail with defaults; assert valid PIL response."""
|
|
self._category = 'detail_endpoint'
|
|
print("\n--- /sdapi/v1/detail Basic ---")
|
|
|
|
if self._critical_error:
|
|
self.skip('detail_basic', self._critical_error)
|
|
return None
|
|
if not self.image_b64:
|
|
self.skip('detail_basic', 'no test image')
|
|
return None
|
|
|
|
t0 = time.time()
|
|
data = self._detail(detailer_strength=0.3, detailer_steps=5, detailer_conf=0.3)
|
|
t1 = time.time()
|
|
if 'error' in data:
|
|
self.record(False, 'detail_basic', f"error: {data}")
|
|
return None
|
|
has_image = 'image' in data and data['image']
|
|
self.record(has_image, 'detail_basic_has_image', f"time={t1 - t0:.1f}s")
|
|
if has_image:
|
|
arr = self._decode_b64_image(data['image'])
|
|
self.record(arr is not None, 'detail_basic_image_valid', f"shape={arr.shape if arr is not None else 'invalid'}")
|
|
return arr
|
|
return None
|
|
|
|
def test_detail_endpoint_strength_effect(self):
|
|
"""Verify per-request strength override changes the output (measured inside the detected face box)."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing detail strength variation...")
|
|
|
|
weak = self._detail(detailer_strength=0.3, detailer_steps=5, detailer_conf=0.3)
|
|
strong = self._detail(detailer_strength=0.7, detailer_steps=5, detailer_conf=0.3)
|
|
if 'error' in weak or 'error' in strong:
|
|
self.record(False, 'detail_strength_effect', f"weak={weak.get('error')} strong={strong.get('error')}")
|
|
return
|
|
weak_arr = self._decode_b64_image(weak['image'])
|
|
strong_arr = self._decode_b64_image(strong['image'])
|
|
box = self._detect_box(self.image_b64, self.face_models)
|
|
diff = self._region_diff(weak_arr, strong_arr, box)
|
|
self.record(diff > 0.5, 'detail_strength_effect', f"region diff={diff:.2f}")
|
|
|
|
def test_detail_endpoint_includes_detections(self):
|
|
"""When detailer_include_detections=True, response should contain detections b64."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing include_detections...")
|
|
|
|
data = self._detail(detailer_strength=0.3, detailer_steps=5, detailer_conf=0.3, detailer_include_detections=True)
|
|
if 'error' in data:
|
|
self.record(False, 'detail_includes_detections', f"error: {data}")
|
|
return
|
|
has_detections = 'detections' in data and data['detections']
|
|
if has_detections:
|
|
arr = self._decode_b64_image(data['detections'])
|
|
self.record(arr is not None, 'detail_includes_detections', f"detections shape={arr.shape if arr is not None else 'invalid'}")
|
|
else:
|
|
# No detections returned could mean the model didn't find anything; not a hard failure
|
|
self.skip('detail_includes_detections', 'no detections returned (model found nothing?)')
|
|
|
|
def test_detail_endpoint_param_isolation(self):
|
|
"""After /sdapi/v1/detail run, baseline txt2img should be unchanged from before."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing param isolation...")
|
|
|
|
before = self._txt2img()
|
|
if 'error' in before:
|
|
self.skip('detail_param_isolation', f'baseline failed: {before}')
|
|
return
|
|
before_arr = self._decode_image(before)
|
|
|
|
detail_resp = self._detail(detailer_strength=0.5, detailer_steps=5)
|
|
if 'error' in detail_resp:
|
|
self.skip('detail_param_isolation', f'detail call failed: {detail_resp}')
|
|
return
|
|
|
|
after = self._txt2img()
|
|
if 'error' in after:
|
|
self.skip('detail_param_isolation', f'after-baseline failed: {after}')
|
|
return
|
|
after_arr = self._decode_image(after)
|
|
leak = self._pixel_diff(before_arr, after_arr)
|
|
self.record(leak < 0.5, 'detail_param_isolation', f"leak={leak:.4f}" if leak < 0.5 else f"LEAK detected (diff={leak:.2f})")
|
|
|
|
def _pick_named_sampler(self):
|
|
"""Return a concrete (non-Default) sampler name from the server, falling back to a common one."""
|
|
data = self._get('/sdapi/v1/samplers')
|
|
if isinstance(data, list):
|
|
for s in data:
|
|
name = s.get('name', '') if isinstance(s, dict) else str(s)
|
|
if name and name.lower() != 'default':
|
|
return name
|
|
return 'Euler a'
|
|
|
|
def test_detail_endpoint_sampler_block(self):
|
|
"""Exercise the full sampler block end-to-end (named sampler + scheduler knobs + cfg + options); assert a valid image."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing sampler block (smoke)...")
|
|
sampler = self._pick_named_sampler()
|
|
data = self._detail(
|
|
detailer_strength=0.5, detailer_steps=5, detailer_conf=0.3,
|
|
detailer_sampler=sampler, detailer_prediction='epsilon', detailer_shift=4.0, detailer_cfg_scale=8.0,
|
|
detailer_loworder=True, detailer_thresholding=False, detailer_dynamic=False, detailer_rescale=False,
|
|
)
|
|
if 'error' in data:
|
|
self.record(False, 'detail_sampler_block', f"sampler={sampler} error: {data}")
|
|
return
|
|
arr = self._decode_b64_image(data['image']) if data.get('image') else None
|
|
self.record(arr is not None, 'detail_sampler_block', f"sampler={sampler} shape={arr.shape if arr is not None else 'invalid'}")
|
|
|
|
def test_detail_endpoint_scheduler_isolation(self):
|
|
"""A sampler-block override must not leak into the global schedulers_shift opt (job-local independence)."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing scheduler isolation...")
|
|
opts_before = self._get('/sdapi/v1/options')
|
|
if 'error' in opts_before:
|
|
self.skip('detail_scheduler_isolation', f'options read failed: {opts_before}')
|
|
return
|
|
shift_before = opts_before.get('schedulers_shift')
|
|
resp = self._detail(detailer_strength=0.5, detailer_steps=5, detailer_sampler=self._pick_named_sampler(), detailer_shift=8.0)
|
|
if 'error' in resp:
|
|
self.skip('detail_scheduler_isolation', f'detail call failed: {resp}')
|
|
return
|
|
opts_after = self._get('/sdapi/v1/options')
|
|
shift_after = opts_after.get('schedulers_shift') if 'error' not in opts_after else None
|
|
ok = shift_after == shift_before
|
|
self.record(ok, 'detail_scheduler_isolation', f"schedulers_shift {shift_before} -> {shift_after}" if ok else f"LEAK: schedulers_shift {shift_before} -> {shift_after}")
|
|
|
|
def test_detail_endpoint_seed_reproducibility(self):
|
|
"""Same fixed seed reproduces the detailed region; a different seed changes it (strength 0.7)."""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing seed reproducibility...")
|
|
a1 = self._detail(detailer_strength=0.7, detailer_steps=5, detailer_conf=0.3, seed=42)
|
|
a2 = self._detail(detailer_strength=0.7, detailer_steps=5, detailer_conf=0.3, seed=42)
|
|
b = self._detail(detailer_strength=0.7, detailer_steps=5, detailer_conf=0.3, seed=1234)
|
|
if 'error' in a1 or 'error' in a2 or 'error' in b:
|
|
self.record(False, 'detail_seed_reproducibility', f"a1={a1.get('error')} a2={a2.get('error')} b={b.get('error')}")
|
|
return
|
|
a1_arr = self._decode_b64_image(a1['image'])
|
|
a2_arr = self._decode_b64_image(a2['image'])
|
|
b_arr = self._decode_b64_image(b['image'])
|
|
box = self._detect_box(self.image_b64, self.face_models)
|
|
same = self._region_diff(a1_arr, a2_arr, box)
|
|
diff = self._region_diff(a1_arr, b_arr, box)
|
|
ok = same < 2.0 and diff > 4.0
|
|
self.record(ok, 'detail_seed_reproducibility', f"same-seed={same:.2f} diff-seed={diff:.2f}")
|
|
|
|
def test_detail_endpoint_cfg_effect(self):
|
|
"""Guidance scale at extremes (1 vs 15, fixed seed) changes the detailed region.
|
|
|
|
CFG scales the conditional-minus-unconditional direction, so a prompt is required: with an empty
|
|
prompt the conditional equals the unconditional and guidance_scale has no effect at any value.
|
|
"""
|
|
self._category = 'detail_endpoint'
|
|
print(" Testing CFG effect...")
|
|
prompt = 'a photo of an elderly bearded man'
|
|
low = self._detail(detailer_strength=0.7, detailer_steps=10, detailer_conf=0.3, detailer_prompt=prompt, detailer_cfg_scale=1.0, seed=42)
|
|
high = self._detail(detailer_strength=0.7, detailer_steps=10, detailer_conf=0.3, detailer_prompt=prompt, detailer_cfg_scale=15.0, seed=42)
|
|
if 'error' in low or 'error' in high:
|
|
self.record(False, 'detail_cfg_effect', f"low={low.get('error')} high={high.get('error')}")
|
|
return
|
|
low_arr = self._decode_b64_image(low['image'])
|
|
high_arr = self._decode_b64_image(high['image'])
|
|
box = self._detect_box(self.image_b64, self.face_models)
|
|
diff = self._region_diff(low_arr, high_arr, box)
|
|
self.record(diff > 0.5, 'detail_cfg_effect', f"region diff={diff:.2f}")
|
|
|
|
# =========================================================================
|
|
# Tests: extras API with script_args (Phase 1 backward-compat + new path)
|
|
# =========================================================================
|
|
|
|
def test_extras_with_detailer_script_args(self):
|
|
"""POST /sdapi/v1/extra-single-image with script_args={'Detailer': {...}} should run the detailer."""
|
|
self._category = 'extras_script_args'
|
|
print("\n--- Extras API with Detailer script_args ---")
|
|
|
|
if not self.image_b64:
|
|
self.skip('extras_script_args', 'no test image')
|
|
return
|
|
|
|
# Baseline: extras without script_args (just upscale=None pass-through)
|
|
payload = {
|
|
'image': self.image_b64,
|
|
'upscaler_1': 'None',
|
|
'upscaling_resize': 1.0,
|
|
}
|
|
baseline = self._post('/sdapi/v1/extra-single-image', payload)
|
|
if 'error' in baseline:
|
|
self.record(False, 'extras_baseline_no_script_args', f"error: {baseline}")
|
|
return
|
|
self.record('image' in baseline and baseline['image'], 'extras_baseline_no_script_args')
|
|
baseline_arr = self._decode_b64_image(baseline['image']) if 'image' in baseline else None
|
|
|
|
# With Detailer script_args
|
|
payload_with_detailer = {
|
|
'image': self.image_b64,
|
|
'upscaler_1': 'None',
|
|
'upscaling_resize': 1.0,
|
|
'script_args': {
|
|
'Detailer': {
|
|
'enabled': True,
|
|
'strength': 0.5,
|
|
'steps': 5,
|
|
'resolution': 1024,
|
|
},
|
|
},
|
|
}
|
|
with_detailer = self._post('/sdapi/v1/extra-single-image', payload_with_detailer)
|
|
if 'error' in with_detailer:
|
|
self.record(False, 'extras_with_detailer_script_args', f"error: {with_detailer}")
|
|
return
|
|
self.record('image' in with_detailer and with_detailer['image'], 'extras_with_detailer_script_args')
|
|
|
|
# Output should differ from baseline (detailer ran)
|
|
if baseline_arr is not None and 'image' in with_detailer:
|
|
with_arr = self._decode_b64_image(with_detailer['image'])
|
|
diff = self._pixel_diff(baseline_arr, with_arr)
|
|
# Diff > 0 means detailer modified the image (or no face found, in which case diff = 0)
|
|
self.record(True, 'extras_script_args_diff', f"baseline vs with-detailer diff={diff:.2f}")
|
|
|
|
# =========================================================================
|
|
# Environment setup: load Anima base unquantized for the run
|
|
# =========================================================================
|
|
|
|
def _find_checkpoint(self, query):
|
|
"""Title of the first /sdapi/v1/sd-models entry containing every term in `query`, else None.
|
|
Anima 1.0 Base ships as an sdnext reference model, so 'anima base' resolves once it is present."""
|
|
data = self._get('/sdapi/v1/sd-models')
|
|
if 'error' in data or not isinstance(data, list):
|
|
return None
|
|
terms = query.lower().split()
|
|
for m in data:
|
|
title = (m.get('title') or m.get('model_name') or '')
|
|
if all(t in title.lower() for t in terms):
|
|
return title
|
|
return None
|
|
|
|
def _reload_checkpoint(self):
|
|
"""Force a clean reload of the selected checkpoint so pending quantization settings take effect."""
|
|
try:
|
|
requests.post(f'{self.base_url}/sdapi/v1/reload-checkpoint', params={'force': 'true'}, timeout=600, verify=False)
|
|
except requests.exceptions.RequestException as e:
|
|
print(f" WARNING: reload-checkpoint failed: {e}")
|
|
|
|
def _setup_environment(self):
|
|
"""Load the test model with SDNQ quantization disabled. The quantized int8 matmul is torch.compiled
|
|
with fullgraph=True/dynamic=False, so the many resolutions/prompts this suite runs exhaust Dynamo's
|
|
recompile limit and hard-crash. Returns the prior options to restore, or None if the API is unavailable."""
|
|
current = self._get('/sdapi/v1/options')
|
|
if 'error' in current:
|
|
print(f" WARNING: GET options failed ({current}); running against current server state")
|
|
return None
|
|
saved = {k: current.get(k) for k in ('sdnq_quantize_weights', 'sd_model_checkpoint')}
|
|
checkpoint = self._find_checkpoint(self.model_query)
|
|
payload = {'sdnq_quantize_weights': []}
|
|
if checkpoint:
|
|
payload['sd_model_checkpoint'] = checkpoint
|
|
self._post('/sdapi/v1/options', payload)
|
|
self._reload_checkpoint()
|
|
print(f" Environment: quantization disabled (was {saved['sdnq_quantize_weights']}), model={checkpoint or '(unchanged)'}")
|
|
return saved
|
|
|
|
def _restore_environment(self, saved):
|
|
"""Restore the options changed by _setup_environment and reload, leaving the server as found."""
|
|
if not saved:
|
|
return
|
|
self._post('/sdapi/v1/options', saved)
|
|
self._reload_checkpoint()
|
|
print(f" Environment restored: sdnq_quantize_weights={saved.get('sdnq_quantize_weights')}, model={saved.get('sd_model_checkpoint')}")
|
|
|
|
# =========================================================================
|
|
# Runner
|
|
# =========================================================================
|
|
|
|
def run_all(self):
|
|
print("=" * 60)
|
|
print("YOLO Detailer API Test Suite")
|
|
print(f"Server: {self.base_url}")
|
|
print("=" * 60)
|
|
|
|
# Enumerate
|
|
models = self.test_detailers_list()
|
|
self.face_models = self._pick_region_models(models)
|
|
|
|
# Load Anima base unquantized for the run; restored in the finally below
|
|
saved_env = self._setup_environment()
|
|
try:
|
|
# Detect across all loaded test images
|
|
self.test_detect_all_images(models)
|
|
# Test with first available model if any
|
|
if models and len(models) > 0:
|
|
model_name = models[0].get('name', models[0].get('filename', ''))
|
|
if model_name:
|
|
self.test_detect_with_model(model_name)
|
|
|
|
# Generate
|
|
self.test_txt2img_without_detailer()
|
|
self.test_txt2img_with_detailer()
|
|
|
|
# Per-request detailer param validation
|
|
self.run_detailer_param_tests(models)
|
|
|
|
# Standalone /sdapi/v1/detail endpoint
|
|
self.test_detail_endpoint_basic()
|
|
self.test_detail_endpoint_strength_effect()
|
|
self.test_detail_endpoint_includes_detections()
|
|
self.test_detail_endpoint_param_isolation()
|
|
self.test_detail_endpoint_sampler_block()
|
|
self.test_detail_endpoint_scheduler_isolation()
|
|
self.test_detail_endpoint_seed_reproducibility()
|
|
self.test_detail_endpoint_cfg_effect()
|
|
|
|
# Extras API with script_args (Detailer script + backward-compat)
|
|
self.test_extras_with_detailer_script_args()
|
|
finally:
|
|
self._restore_environment(saved_env)
|
|
|
|
# Summary
|
|
print("\n" + "=" * 60)
|
|
print("Results")
|
|
print("=" * 60)
|
|
total_passed = 0
|
|
total_failed = 0
|
|
total_skipped = 0
|
|
for cat, data in self.results.items():
|
|
total_passed += data['passed']
|
|
total_failed += data['failed']
|
|
total_skipped += data['skipped']
|
|
status = 'PASS' if data['failed'] == 0 else 'FAIL'
|
|
print(f" {cat}: {data['passed']} passed, {data['failed']} failed, {data['skipped']} skipped [{status}]")
|
|
print(f" Total: {total_passed} passed, {total_failed} failed, {total_skipped} skipped")
|
|
print("=" * 60)
|
|
return total_failed == 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description='YOLO Detailer API Tests')
|
|
parser.add_argument('--url', default=os.environ.get('SDAPI_URL', 'http://127.0.0.1:7860'), help='server URL')
|
|
parser.add_argument('--image', default=None, help='test image path')
|
|
parser.add_argument('--model', default='anima base', help="checkpoint to load for the run (substring match against /sdapi/v1/sd-models titles)")
|
|
args = parser.parse_args()
|
|
test = DetailerAPITest(args.url, args.image, model_query=args.model)
|
|
success = test.run_all()
|
|
sys.exit(0 if success else 1)
|