From f9ab0bf04de927b3b4fd37981fc56fa4ff864d53 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 7 May 2026 22:47:43 +0100 Subject: [PATCH] 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 --- modules/api/api.py | 1 + modules/api/models.py | 39 +++ modules/api/process.py | 85 ++++- modules/postprocess/yolo.py | 74 ++++- modules/postprocessing.py | 10 +- modules/processing_info.py | 6 +- scripts/postprocessing_detailer.py | 76 +++++ test/test-detailer-api.py | 478 ++++++++++++++++++++++++++--- ui/locale/locale_en.json | 2 +- 9 files changed, 715 insertions(+), 56 deletions(-) create mode 100644 scripts/postprocessing_detailer.py diff --git a/modules/api/api.py b/modules/api/api.py index 0eb6c30e9..57be1c8e4 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -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 diff --git a/modules/api/models.py b/modules/api/models.py index e00d06e5e..96fa7343f 100644 --- a/modules/api/models.py +++ b/modules/api/models.py @@ -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") diff --git a/modules/api/process.py b/modules/api/process.py index 641703989..ca9ba3346 100644 --- a/modules/api/process.py +++ b/modules/api/process.py @@ -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.. + 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]) diff --git a/modules/postprocess/yolo.py b/modules/postprocess/yolo.py index b89ec5aac..b3cbe7db0 100644 --- a/modules/postprocess/yolo.py +++ b/modules/postprocess/yolo.py @@ -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 diff --git a/modules/postprocessing.py b/modules/postprocessing.py index 7e0717637..cbdf55ca6 100644 --- a/modules/postprocessing.py +++ b/modules/postprocessing.py @@ -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) diff --git a/modules/processing_info.py b/modules/processing_info.py index 4312ceb41..142db752d 100644 --- a/modules/processing_info.py +++ b/modules/processing_info.py @@ -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"]: diff --git a/scripts/postprocessing_detailer.py b/scripts/postprocessing_detailer.py new file mode 100644 index 000000000..23dc2d676 --- /dev/null +++ b/scripts/postprocessing_detailer.py @@ -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 diff --git a/test/test-detailer-api.py b/test/test-detailer-api.py index 3cc966e5a..7d5e10aa6 100644 --- a/test/test-detailer-api.py +++ b/test/test-detailer-api.py @@ -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) diff --git a/ui/locale/locale_en.json b/ui/locale/locale_en.json index 187a0d2a3..6714b66ad 100644 --- a/ui/locale/locale_en.json +++ b/ui/locale/locale_en.json @@ -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 YOLO detector finds target regions (faces, eyes, hands, persons, etc.) and each detected region is re-rendered with inpaint at the configured detailer resolution.
Useful for fixing distorted faces or hands at low base resolutions, sharpening eye detail, or adding a second-pass refinement to specific subjects.

Default off.","ui":"txt2img"}, + {"id":"","label":"Enable detailer pass","localized":"","hint":"Runs an automatic touch-up pass: a YOLO 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 Base model.
Runs after generation in the image tabs, or standalone on the input image in the Process tab.
Useful for fixing distorted faces or hands at low base resolutions, sharpening eye detail, or adding a second-pass refinement to specific subjects.

Default off.","ui":"txt2img"}, {"id":"","label":"Edge padding","localized":"","hint":"Pixels added around each detection's bounding box when cropping the region for inpaint.
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.

Default 20.","ui":"txt2img"}, {"id":"","label":"Edge blur","localized":"","hint":"Pixel radius of the Gaussian blur applied to the inpaint mask edge.
Softens the boundary between the regenerated region and the rest of the image so the paste-back blends instead of cutting hard.

Set to 0 to disable.
Default 10.","ui":"txt2img"}, {"id":"","label":"End","localized":"","hint":"","ui":"txt2img"},