Merge pull request #4886 from vladmandic/feat/detailer-postprocess

feat(api): add detailer postprocess script and /sdapi/v1/detail endpoint
This commit is contained in:
Vladimir Mandic
2026-06-02 07:15:11 +02:00
committed by GitHub
9 changed files with 715 additions and 56 deletions
+1
View File
@@ -69,6 +69,7 @@ class Api:
self.add_api_route("/sdapi/v1/preprocess", self.process.post_preprocess, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/mask", self.process.post_mask, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/detect", self.process.post_detect, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/detail", self.process.post_detail, methods=["POST"], response_model=models.ResDetail, tags=["Processing"])
self.add_api_route("/sdapi/v1/prompt-enhance", self.process.post_prompt_enhance, methods=["POST"], response_model=models.ResPromptEnhance, tags=["Generation"])
# api dealing with optional scripts
+39
View File
@@ -343,6 +343,7 @@ class ReqProcess(BaseModel):
upscaler_1: str = Field(default="None", title="Main upscaler", description=f"The name of the main upscaler to use, it has to be one of this list: {' , '.join([x.name for x in shared.sd_upscalers])}")
upscaler_2: str = Field(default="None", title="Refine upscaler", description=f"The name of the secondary upscaler to use, it has to be one of this list: {' , '.join([x.name for x in shared.sd_upscalers])}")
extras_upscaler_2_visibility: float = Field(default=0, title="Refine upscaler visibility", ge=0, le=1, allow_inf_nan=False, description="Sets the visibility of secondary upscaler, values should be between 0 and 1.")
script_args: dict | None = Field(default=None, title="Script args", description="Per-script arguments keyed by script name, e.g. {\"Detailer\": {\"strength\": 0.5}, \"Remove background\": {\"model\": \"u2net\"}}.")
class ResProcess(BaseModel):
html_info: str = Field(title="HTML info", description="A series of HTML tags containing the process info.")
@@ -391,6 +392,44 @@ class ReqProcessBatch(ReqProcess):
class ResProcessBatch(ResProcess):
images: list[str] = Field(title="Images", description="The generated images in base64 format.")
class ReqDetail(BaseModel):
image: str = Field(title="Image", description="Base64-encoded input image to detail")
seed: int | None = Field(default=-1, title="Seed", description="Seed for inpainting passes (-1 = random)")
detailer_models: list[str] | None = Field(default=None, title="Detailer models", description="List of YOLO detailer model names to run; falls back to shared.opts.detailer_models when omitted")
detailer_prompt: str | None = Field(default=None, title="Detailer prompt", description="Override prompt for detailer pass; supports [PROMPT]/[prompt] splice tokens")
detailer_negative: str | None = Field(default=None, title="Detailer negative", description="Override negative prompt for detailer pass")
detailer_steps: int | None = Field(default=None, ge=0, le=99, title="Detailer steps")
detailer_strength: float | None = Field(default=None, ge=0.0, le=1.0, title="Detailer strength")
detailer_resolution: int | None = Field(default=None, ge=256, le=4096, title="Detailer resolution")
detailer_sampler: str | None = Field(default=None, title="Detailer sampler", description="Sampler name for the inpaint pass; a named sampler activates the scheduler overrides below, 'Default' keeps the model scheduler")
detailer_prediction: str | None = Field(default=None, title="Detailer prediction", description="Scheduler prediction type override (default/epsilon/sample/v_prediction/flow_prediction)")
detailer_shift: float | None = Field(default=None, ge=0.0, le=10.0, title="Detailer flow shift", description="Flow/sampler shift for the inpaint pass; needs a named sampler")
detailer_cfg_scale: float | None = Field(default=None, ge=0.0, le=30.0, title="Detailer guidance scale", description="CFG/guidance scale for the inpaint pass")
detailer_loworder: bool | None = Field(default=None, title="Detailer low order")
detailer_thresholding: bool | None = Field(default=None, title="Detailer thresholding")
detailer_dynamic: bool | None = Field(default=None, title="Detailer dynamic shift")
detailer_rescale: bool | None = Field(default=None, title="Detailer rescale betas")
detailer_classes: str | None = Field(default=None, title="Detailer classes", description="Comma-separated class allowlist (e.g. 'face,eye')")
detailer_conf: float | None = Field(default=None, ge=0.0, le=1.0, title="Min confidence")
detailer_iou: float | None = Field(default=None, ge=0.0, le=1.0, title="Max overlap (IoU)")
detailer_max: int | None = Field(default=None, ge=1, title="Max detections")
detailer_min_size: float | None = Field(default=None, ge=0.0, le=1.0, title="Min relative size")
detailer_max_size: float | None = Field(default=None, ge=0.0, le=1.0, title="Max relative size")
detailer_blur: int | None = Field(default=None, ge=0, le=100, title="Mask blur")
detailer_padding: int | None = Field(default=None, ge=0, le=100, title="Mask padding")
detailer_segmentation: bool | None = Field(default=None, title="Use segmentation", description="Use seg-mask instead of bbox (requires a -seg model)")
detailer_merge: bool | None = Field(default=None, title="Merge detections")
detailer_sort: bool | None = Field(default=None, title="Sort detections", description="Sort detections left-to-right for consistency")
detailer_sigma_adjust: float | None = Field(default=None, ge=0.5, le=1.5, title="Renoise sigma")
detailer_sigma_adjust_max: float | None = Field(default=None, ge=0.0, le=1.0, title="Renoise end")
detailer_include_detections: bool | None = Field(default=None, title="Include detections", description="Return annotated debug image alongside the detailed result")
class ResDetail(BaseModel):
image: str = Field(title="Image", description="Detailed image (base64)")
detections: str | None = Field(default=None, title="Detections", description="Annotated debug image (base64) when detailer_include_detections=True")
seed: int = Field(default=-1, title="Seed", description="Effective seed used for the detailer pass")
info: str = Field(default='', title="Info", description="Postprocessing info string")
class ReqImageInfo(BaseModel):
image: str = Field(title="Image", description="The base64 encoded image")
+80 -5
View File
@@ -137,6 +137,80 @@ class APIProcess:
shared.state.end(jobid, api=False)
return ResFace(classes=classes, labels=labels, scores=scores, boxes=boxes, images=images)
def post_detail(self, req: models.ReqDetail):
"""Run the YOLO detailer on a single image as a standalone operation; no base generation pass.
Per-request fields override shared.opts.detailer_* via detailer_opt(p, attr) precedence
in modules/postprocess/yolo.py. Fields left as None fall through to the global setting.
"""
import numpy as np
from PIL import Image
from modules.shared import yolo # pylint: disable=no-name-in-module
if shared.sd_model is None or not hasattr(shared.sd_model, 'sd_checkpoint_info'):
return JSONResponse(status_code=400, content={"error": "no base model selected"})
image = decode_base64_to_image(req.image)
if image is None:
return JSONResponse(status_code=400, content={"error": "invalid image"})
# Per-request overrides for the non-primary detailer fields; None values fall through to opts
# via detailer_opt(p, attr) -> shared.opts.<attr>.
overrides = {attr: getattr(req, attr) for attr in (
'detailer_models', 'detailer_classes', 'detailer_conf',
'detailer_iou', 'detailer_max', 'detailer_min_size',
'detailer_max_size', 'detailer_blur', 'detailer_padding',
'detailer_segmentation', 'detailer_merge', 'detailer_sort',
'detailer_sigma_adjust', 'detailer_sigma_adjust_max',
'detailer_include_detections',
) if getattr(req, attr, None) is not None}
# Sampler block: request field names differ from the p attributes they set, so map explicitly.
# schedulers_* become per-job overrides (need a named sampler to take effect); cfg/sampler apply directly.
for req_attr, p_attr in (
('detailer_sampler', 'hr_sampler_name'),
('detailer_prediction', 'schedulers_prediction_type'),
('detailer_shift', 'schedulers_shift'),
('detailer_cfg_scale', 'cfg_scale'),
('detailer_loworder', 'schedulers_use_loworder'),
('detailer_thresholding', 'schedulers_use_thresholding'),
('detailer_dynamic', 'schedulers_dynamic_shift'),
('detailer_rescale', 'schedulers_rescale_betas'),
):
val = getattr(req, req_attr, None)
if val is not None:
overrides[p_attr] = val
jobid = shared.state.begin('API-DETAIL', api=True)
try:
p = yolo.make_processing(
image,
prompt=req.detailer_prompt or '',
negative=req.detailer_negative or '',
steps=req.detailer_steps if req.detailer_steps is not None else 10,
strength=req.detailer_strength if req.detailer_strength is not None else 0.3,
resolution=req.detailer_resolution if req.detailer_resolution is not None else 1024,
seed=req.seed if req.seed is not None else -1,
overrides=overrides,
)
with self.queue_lock:
result = yolo.restore(np.array(image), p)
annotated_b64 = None
if isinstance(result, list) and len(result) > 0:
out_image = Image.fromarray(result[0])
if len(result) > 1 and result[1] is not None:
annotated = result[1] if isinstance(result[1], Image.Image) else Image.fromarray(result[1])
annotated_b64 = encode_pil_to_base64(annotated)
elif isinstance(result, np.ndarray):
out_image = Image.fromarray(result)
else:
return JSONResponse(status_code=500, content={"error": "detailer produced no result"})
return models.ResDetail(image=encode_pil_to_base64(out_image), detections=annotated_b64, seed=p.all_seeds[0])
finally:
shared.state.end(jobid, api=False)
def post_prompt_enhance(self, req: models.ReqPromptEnhance):
"""Enhance a prompt using an LLM. Supports text, image-conditioned, and video prompt enhancement modes."""
from modules import processing_helpers
@@ -209,23 +283,24 @@ class APIProcess:
def set_upscalers(self, req: dict):
reqDict = vars(req)
script_args = reqDict.pop('script_args', None)
reqDict['extras_upscaler_1'] = reqDict.pop('upscaler_1', None)
reqDict['extras_upscaler_2'] = reqDict.pop('upscaler_2', None)
return reqDict
return reqDict, script_args
def extras_single_image_api(self, req: models.ReqProcessImage):
"""Upscale or postprocess a single image using the configured upscaler pipeline."""
reqDict = self.set_upscalers(req)
reqDict, script_args = self.set_upscalers(req)
reqDict['image'] = helpers.decode_base64_to_image(reqDict['image'])
with self.queue_lock:
result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", save_output=False, **reqDict)
result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict)
return models.ResProcessImage(image=helpers.encode_pil_to_base64(result[0][0]), html_info=result[1])
def extras_batch_images_api(self, req: models.ReqProcessBatch):
"""Upscale or postprocess a batch of images using the configured upscaler pipeline."""
reqDict = self.set_upscalers(req)
reqDict, script_args = self.set_upscalers(req)
image_list = reqDict.pop('imageList', [])
image_folder = [helpers.decode_base64_to_image(x.data) for x in image_list]
with self.queue_lock:
result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", save_output=False, **reqDict)
result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict)
return models.ResProcessBatch(images=list(map(helpers.encode_pil_to_base64, result[0])), html_info=result[1])
+72 -2
View File
@@ -483,6 +483,51 @@ class YoloRestorer(Detailer):
np_images.append(annotated) # save debug image with boxes
return np_images
def make_processing(self, image, prompt='', negative='', steps=10, strength=0.3, resolution=1024, seed=-1, overrides=None):
"""Build a synthetic Img2Img processing object to run restore() standalone, with no base generation pass.
The primary params map to the detailer_* fields restore() reads directly. overrides is an optional
dict of the remaining detailer_* settings; None values are skipped and fall through to shared.opts
via detailer_opt(). The seed is resolved here so restore()'s inpaint passes are reproducible and the
effective value can be reported back.
"""
from modules.processing_helpers import get_fixed_seed
from modules.paths import resolve_output_path
seed = int(get_fixed_seed(seed))
outpath = resolve_output_path(shared.opts.outdir_samples, shared.opts.outdir_extras_samples)
p = processing.StableDiffusionProcessingImg2Img(
sd_model=shared.sd_model,
prompt=prompt or '',
negative_prompt=negative or '',
init_images=[image],
outpath_samples=outpath,
outpath_grids=outpath,
batch_size=1,
n_iter=1,
seed=seed,
width=image.width,
height=image.height,
detailer_enabled=True,
detailer_prompt=prompt or '',
detailer_negative=negative or '',
detailer_steps=steps,
detailer_strength=strength,
detailer_resolution=resolution,
)
for attr, val in (overrides or {}).items():
if val is not None:
setattr(p, attr, val)
# restore() at yolo.py reads all_prompts[0]/all_negative_prompts[0]; the rest avoid AttributeError downstream
p.all_prompts = [p.detailer_prompt or '']
p.all_negative_prompts = [p.detailer_negative or '']
p.all_seeds = [seed]
p.all_subseeds = [-1]
p.scripts = None
p.is_control = False
p.do_not_save_samples = True
p.do_not_save_grid = True
return p
def change_mode(self, dropdown, text):
self.ui_mode = not self.ui_mode
if self.ui_mode:
@@ -533,10 +578,16 @@ class YoloRestorer(Detailer):
ui_mode.click(fn=self.change_mode, inputs=[detailers, detailers_text], outputs=[detailers, detailers_text, refresh_btn])
with gr.Row():
classes = gr.Textbox(label="Detailer classes", placeholder="Classes", elem_id=f"{tab}_detailer_classes")
if tab == 'extras': # Process tab is standalone, there is no base prompt to fall back to
prompt_placeholder = 'detailer prompt, leave empty for none'
negative_placeholder = 'detailer negative prompt, leave empty for none'
else:
prompt_placeholder = 'detailer prompt or leave empty to use main prompt'
negative_placeholder = 'detailer prompt or leave empty to use main prompt'
with gr.Row():
prompt = gr.Textbox(label="Detailer prompt", value='', placeholder='detailer prompt or leave empty to use main prompt', lines=2, elem_id=f"{tab}_detailer_prompt", elem_classes=["prompt"])
prompt = gr.Textbox(label="Detailer prompt", value='', placeholder=prompt_placeholder, lines=2, elem_id=f"{tab}_detailer_prompt", elem_classes=["prompt"])
with gr.Row():
negative = gr.Textbox(label="Detailer negative prompt", value='', placeholder='detailer prompt or leave empty to use main prompt', lines=2, elem_id=f"{tab}_detailer_negative", elem_classes=["prompt"])
negative = gr.Textbox(label="Detailer negative prompt", value='', placeholder=negative_placeholder, lines=2, elem_id=f"{tab}_detailer_negative", elem_classes=["prompt"])
with gr.Row():
steps = gr.Slider(label="Detailer steps", elem_id=f"{tab}_detailer_steps", value=10, minimum=0, maximum=99, step=1)
strength = gr.Slider(label="Detailer strength", elem_id=f"{tab}_detailer_strength", value=0.3, minimum=0, maximum=1, step=0.01)
@@ -557,6 +608,23 @@ class YoloRestorer(Detailer):
with gr.Row(elem_classes=['flex-break']):
renoise_value = gr.Slider(minimum=0.5, maximum=1.5, step=0.01, label='Renoise', value=shared.opts.detailer_sigma_adjust, elem_id=f"{tab}_detailer_renoise")
renoise_end = gr.Slider(minimum=0.0, maximum=1.0, step=0.01, label='Renoise end', value=shared.opts.detailer_sigma_adjust_max, elem_id=f"{tab}_detailer_renoise_end")
sampler_block = None
if tab == 'extras': # fold the standalone sampler settings into the detailer accordion; values applied per-job in make_processing, never global opts
from modules import sd_samplers
sd_samplers.set_samplers()
sampler_choices = [s.name for s in sd_samplers.samplers if s.name != 'Same as primary']
with gr.Accordion('Sampler', open=False, elem_id=f"{tab}_detailer_sampler_accordion", elem_classes=["small-accordion"]):
with gr.Row():
d_sampler = gr.Dropdown(label='Sampling method', choices=sampler_choices, value='Default', elem_id=f"{tab}_detailer_sampler")
d_prediction = gr.Dropdown(label='Prediction method', choices=['default', 'epsilon', 'sample', 'v_prediction', 'flow_prediction'], value='default', elem_id=f"{tab}_detailer_prediction")
with gr.Row():
d_shift = gr.Slider(label='Flow shift', minimum=0, maximum=10, step=0.1, value=shared.opts.schedulers_shift, elem_id=f"{tab}_detailer_shift")
d_cfg = gr.Slider(label='Guidance scale', minimum=0, maximum=30, step=0.1, value=6.0, elem_id=f"{tab}_detailer_cfg")
with gr.Row():
d_options = gr.CheckboxGroup(label='Options', choices=['low order', 'thresholding', 'dynamic', 'rescale'], value=['low order'], elem_id=f"{tab}_detailer_options")
with gr.Row():
d_seed = gr.Number(label='Seed', value=-1, precision=0, elem_id=f"{tab}_detailer_seed")
sampler_block = {'sampler': d_sampler, 'prediction': d_prediction, 'shift': d_shift, 'cfg_scale': d_cfg, 'options': d_options, 'seed': d_seed}
merge.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
detailers.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
@@ -573,6 +641,8 @@ class YoloRestorer(Detailer):
save.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
sort.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
seg.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
if tab == 'extras':
return enabled, prompt, negative, steps, strength, resolution, sampler_block
return enabled, prompt, negative, steps, strength, resolution
+7 -3
View File
@@ -100,10 +100,10 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp
return outputs, info, params
def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True):
def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True, script_args: dict | None = None):
"""old handler for API"""
args = scripts_manager.scripts_postproc.create_args_for_run({
merged = {
"Upscale": {
"upscale_mode": resize_mode,
"upscale_by": upscaling_resize,
@@ -114,6 +114,10 @@ def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_
"upscaler_2_name": extras_upscaler_2,
"upscaler_2_visibility": extras_upscaler_2_visibility,
},
})
}
if script_args:
for name, kvs in script_args.items():
merged.setdefault(name, {}).update(kvs or {})
args = scripts_manager.scripts_postproc.create_args_for_run(merged)
return run_postprocessing(extras_mode, image, image_folder, input_dir, output_dir, show_extras_results, *args, save_output=save_output)
+3 -3
View File
@@ -118,11 +118,11 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
args["Hires steps"] = p.hr_second_pass_steps
args["Hires strength"] = p.hr_denoising_strength
args["Hires sampler"] = p.hr_sampler_name if p.hr_sampler_name != 'Default' else None
args["Hires CFG scale"] = p.cfg_image if p.cfg_image > -1 else None
args["Hires CFG scale"] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
if 'refine' in p.ops:
args["Refine"] = p.enable_hr
args["Refiner"] = None if (not shared.opts.add_model_name_to_info) or (not shared.sd_refiner) or (not shared.sd_refiner.sd_checkpoint_info.model_name) else shared.sd_refiner.sd_checkpoint_info.model_name.replace(',', '').replace(':', '')
args['Hires CFG scale'] = p.cfg_image if p.cfg_image > -1 else None
args['Hires CFG scale'] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
args['Refiner steps'] = p.refiner_steps
args['Refiner start'] = p.refiner_start
args["Hires steps"] = p.hr_second_pass_steps
@@ -130,7 +130,7 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
if ('img2img' in p.ops or 'inpaint' in p.ops) and ('txt2img' not in p.ops and 'hires' not in p.ops): # real img2img/inpaint
args["Init image size"] = f"{getattr(p, 'init_img_width', 0)}x{getattr(p, 'init_img_height', 0)}"
args["Init image hash"] = getattr(p, 'init_img_hash', None)
args['Image CFG scale'] = p.cfg_image if p.cfg_image > -1 else None
args['Image CFG scale'] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
args["Mask weight"] = getattr(p, "inpainting_mask_weight", shared.opts.inpainting_mask_weight) if p.is_using_inpainting_conditioning else None
args["Denoising strength"] = getattr(p, 'denoising_strength', None)
if args["Size"] != args["Init image size"]:
+76
View File
@@ -0,0 +1,76 @@
import numpy as np
from PIL import Image
from modules import scripts_postprocessing, shared
from modules.logger import log
class ScriptPostprocessingDetailer(scripts_postprocessing.ScriptPostprocessing):
name = "Detailer"
order = 15000
def ui(self):
# The detailer accordion (built by yolo.ui) now contains the Sampler sub-accordion too, so for 'extras'
# it returns a 7th element: a dict of the sampler-block controls. Spread it into the control map; their
# values are stamped onto the synthetic p in process()/make_processing(), applying to this pass only.
enabled, prompt, negative, steps, strength, resolution, sampler_block = shared.yolo.ui('extras')
return {
"enabled": enabled,
"prompt": prompt,
"negative": negative,
"steps": steps,
"strength": strength,
"resolution": resolution,
**sampler_block,
}
def process(self, pp: scripts_postprocessing.PostprocessedImage, # pylint: disable=arguments-differ
enabled=False, prompt='', negative='', steps=10, strength=0.3, resolution=1024,
sampler='Default', prediction='default', shift=3.0, cfg_scale=6.0, options=None, seed=-1):
if not enabled:
return pp
if shared.sd_model is None or not hasattr(shared.sd_model, 'sd_checkpoint_info'):
log.warning('Detailer postprocess: no base model selected')
pp.info["Detailer"] = "skipped (no base model selected)"
return pp
# The sampler block is stamped onto the synthetic p. The schedulers_* values become per-job overrides in
# processing_helpers (they beat the global opts for this pass only); a named sampler is required for them
# to take effect, 'Default' keeps the model scheduler. cfg_scale and hr_sampler_name apply directly.
options = options or []
overrides = {
'hr_sampler_name': sampler,
'schedulers_prediction_type': prediction,
'schedulers_shift': shift,
'cfg_scale': cfg_scale,
'schedulers_use_loworder': 'low order' in options,
'schedulers_use_thresholding': 'thresholding' in options,
'schedulers_dynamic_shift': 'dynamic' in options,
'schedulers_rescale_betas': 'rescale' in options,
}
log.info(f'Detailer postprocess: strength={strength} steps={steps} resolution={resolution} sampler={sampler} cfg={cfg_scale}')
p = shared.yolo.make_processing(pp.image, prompt=prompt, negative=negative, steps=steps, strength=strength, resolution=resolution, seed=int(seed) if seed is not None else -1, overrides=overrides)
try:
result = shared.yolo.restore(np.array(pp.image), p)
except Exception as e:
log.error(f'Detailer postprocess: {e}')
return pp
# restore() returns list[ndarray] (detailed image at [0], annotated debug at [1] when enabled)
# on success, or a single ndarray on early-return paths. The postprocessing pipeline is one
# image per input, so the annotated debug image is dropped here; use /sdapi/v1/detail for it.
if isinstance(result, list) and len(result) > 0:
pp.image = Image.fromarray(result[0])
elif isinstance(result, np.ndarray):
pp.image = Image.fromarray(result)
pp.info["Detailer"] = "Enabled"
pp.info["Detailer strength"] = strength
pp.info["Detailer steps"] = steps
pp.info["Detailer resolution"] = resolution
pp.info["Detailer sampler"] = sampler
if prompt:
pp.info["Detailer prompt"] = prompt
if negative:
pp.info["Detailer negative"] = negative
return pp
+436 -42
View File
@@ -43,18 +43,22 @@ FALLBACK_IMAGES = [
class DetailerAPITest:
"""Test harness for YOLO Detailer API endpoints."""
def __init__(self, base_url, image_path=None, timeout=300):
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):
@@ -214,6 +218,18 @@ class DetailerAPITest:
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'
@@ -413,6 +429,38 @@ class DetailerAPITest:
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:
@@ -441,6 +489,13 @@ class DetailerAPITest:
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({
@@ -460,48 +515,57 @@ class DetailerAPITest:
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 --
print(" Testing strength variation...")
# -- 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.7,
'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._pixel_diff(detailer_default, strong)
diff_strong = self._region_diff(detailer_default, strong, box)
self.record(diff_strong > 0.5, 'detailer_strength_effect',
f"strength 0.3 vs 0.7: diff={diff_strong:.2f}")
f"strength 0.3 vs 0.9: region diff={diff_strong:.2f}")
# -- Steps variation --
print(" Testing steps variation...")
# -- 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.3,
'detailer_strength': 0.7,
'detailer_steps': 20,
'detailer_conf': 0.3,
})
if 'error' not in more_steps_data:
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._pixel_diff(detailer_default, more_steps)
diff_steps = self._region_diff(few_steps, more_steps, box)
self.record(diff_steps > 0.5, 'detailer_steps_effect',
f"steps 5 vs 20: diff={diff_steps:.2f}")
f"steps 1 vs 20 @ strength 0.7: region diff={diff_steps:.2f}")
# -- Resolution variation --
print(" Testing resolution variation...")
# -- 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': 512,
'detailer_resolution': 256,
})
if 'error' not in hires_data:
hires = self._decode_image(hires_data)
diff_res = self._pixel_diff(detailer_default, hires)
diff_res = self._region_diff(detailer_default, hires, box)
self.record(diff_res > 0.5, 'detailer_resolution_effect',
f"resolution 1024 vs 512: diff={diff_res:.2f}")
f"resolution 1024 vs 256: region diff={diff_res:.2f}")
# -- Segmentation mode --
# Segmentation requires a -seg model (e.g. anzhc-face-1024-seg-8n).
@@ -531,9 +595,9 @@ class DetailerAPITest:
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._pixel_diff(seg_bbox, seg_mask)
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}): diff={diff_seg:.2f}")
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}")
@@ -558,20 +622,30 @@ class DetailerAPITest:
f"conf=0.95 vs baseline: diff={diff_conf:.2f} "
f"(low diff = detections filtered out, high diff = still detected)")
# -- Custom detailer prompt --
print(" Testing detailer prompt override...")
prompt_data = self._txt2img({
# -- 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.5,
'detailer_steps': 5,
'detailer_strength': 0.7,
'detailer_steps': 10,
'detailer_conf': 0.3,
'detailer_prompt': 'a detailed close-up face with freckles',
'detailer_prompt': 'a photo of an elderly bearded man',
})
if 'error' not in prompt_data:
prompt_result = self._decode_image(prompt_data)
diff_prompt = self._pixel_diff(detailer_default, prompt_result)
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"custom prompt vs default: diff={diff_prompt:.2f}")
f"divergent prompts @ strength 0.7: region diff={diff_prompt:.2f}")
# -- Metadata verification across params --
for test_data, label in [
@@ -597,6 +671,306 @@ class DetailerAPITest:
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
# =========================================================================
@@ -609,21 +983,40 @@ class DetailerAPITest:
# Enumerate
models = self.test_detailers_list()
self.face_models = self._pick_region_models(models)
# 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)
# 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()
# Generate
self.test_txt2img_without_detailer()
self.test_txt2img_with_detailer()
# Per-request detailer param validation
self.run_detailer_param_tests(models)
# 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)
@@ -647,7 +1040,8 @@ 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)
test = DetailerAPITest(args.url, args.image, model_query=args.model)
success = test.run_all()
sys.exit(0 if success else 1)
+1 -1
View File
@@ -440,7 +440,7 @@
{"id":"","label":"Effects","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"Enable LayerSkipConfig","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"Enable refine pass","localized":"","hint":"Use a similar process as image to image to upscale and/or add detail to the final image. Optionally uses refiner model to enhance image details.","ui":"txt2img"},
{"id":"","label":"Enable detailer pass","localized":"","hint":"Runs an automatic touch-up pass after generation: a <i>YOLO</i> detector finds target regions (faces, eyes, hands, persons, etc.) and each detected region is re-rendered with inpaint at the configured detailer resolution.<br>Useful for fixing distorted faces or hands at low base resolutions, sharpening eye detail, or adding a second-pass refinement to specific subjects.<br><br>Default off.","ui":"txt2img"},
{"id":"","label":"Enable detailer pass","localized":"","hint":"Runs an automatic touch-up pass: a <i>YOLO</i> detector finds target regions (faces, eyes, hands, persons, etc.) and each detected region is re-rendered with inpaint at the configured detailer resolution, using the selected <b>Base model</b>.<br>Runs after generation in the image tabs, or standalone on the input image in the <b>Process</b> tab.<br>Useful for fixing distorted faces or hands at low base resolutions, sharpening eye detail, or adding a second-pass refinement to specific subjects.<br><br>Default off.","ui":"txt2img"},
{"id":"","label":"Edge padding","localized":"","hint":"Pixels added around each detection's bounding box when cropping the region for inpaint.<br>Padding gives the inpaint pass surrounding context so the regenerated content can blend smoothly with the rest of the image. Too little causes hard seams; too much wastes resolution on areas that won't change.<br><br>Default 20.","ui":"txt2img"},
{"id":"","label":"Edge blur","localized":"","hint":"Pixel radius of the Gaussian blur applied to the inpaint mask edge.<br>Softens the boundary between the regenerated region and the rest of the image so the paste-back blends instead of cutting hard.<br><br>Set to 0 to disable.<br>Default 10.","ui":"txt2img"},
{"id":"","label":"End","localized":"","hint":"","ui":"txt2img"},