feat(api): add detailer postprocess script and /sdapi/v1/detail endpoint

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
This commit is contained in:
CalamitousFelicitousness
2026-05-07 22:47:43 +01:00
parent c3eec8e60b
commit f9ab0bf04d
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"},