mirror of
https://github.com/vladmandic/automatic
synced 2026-09-18 16:54:33 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user