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

feat(api): add detailer postprocess script and /sdapi/v1/detail endpoint
This commit is contained in:
Vladimir Mandic
2026-06-02 07:15:11 +02:00
committed by GitHub
9 changed files with 715 additions and 56 deletions
+1
View File
@@ -69,6 +69,7 @@ class Api:
self.add_api_route("/sdapi/v1/preprocess", self.process.post_preprocess, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/mask", self.process.post_mask, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/detect", self.process.post_detect, methods=["POST"], tags=["Processing"])
self.add_api_route("/sdapi/v1/detail", self.process.post_detail, methods=["POST"], response_model=models.ResDetail, tags=["Processing"])
self.add_api_route("/sdapi/v1/prompt-enhance", self.process.post_prompt_enhance, methods=["POST"], response_model=models.ResPromptEnhance, tags=["Generation"])
# api dealing with optional scripts
+39
View File
@@ -343,6 +343,7 @@ class ReqProcess(BaseModel):
upscaler_1: str = Field(default="None", title="Main upscaler", description=f"The name of the main upscaler to use, it has to be one of this list: {' , '.join([x.name for x in shared.sd_upscalers])}")
upscaler_2: str = Field(default="None", title="Refine upscaler", description=f"The name of the secondary upscaler to use, it has to be one of this list: {' , '.join([x.name for x in shared.sd_upscalers])}")
extras_upscaler_2_visibility: float = Field(default=0, title="Refine upscaler visibility", ge=0, le=1, allow_inf_nan=False, description="Sets the visibility of secondary upscaler, values should be between 0 and 1.")
script_args: dict | None = Field(default=None, title="Script args", description="Per-script arguments keyed by script name, e.g. {\"Detailer\": {\"strength\": 0.5}, \"Remove background\": {\"model\": \"u2net\"}}.")
class ResProcess(BaseModel):
html_info: str = Field(title="HTML info", description="A series of HTML tags containing the process info.")
@@ -391,6 +392,44 @@ class ReqProcessBatch(ReqProcess):
class ResProcessBatch(ResProcess):
images: list[str] = Field(title="Images", description="The generated images in base64 format.")
class ReqDetail(BaseModel):
image: str = Field(title="Image", description="Base64-encoded input image to detail")
seed: int | None = Field(default=-1, title="Seed", description="Seed for inpainting passes (-1 = random)")
detailer_models: list[str] | None = Field(default=None, title="Detailer models", description="List of YOLO detailer model names to run; falls back to shared.opts.detailer_models when omitted")
detailer_prompt: str | None = Field(default=None, title="Detailer prompt", description="Override prompt for detailer pass; supports [PROMPT]/[prompt] splice tokens")
detailer_negative: str | None = Field(default=None, title="Detailer negative", description="Override negative prompt for detailer pass")
detailer_steps: int | None = Field(default=None, ge=0, le=99, title="Detailer steps")
detailer_strength: float | None = Field(default=None, ge=0.0, le=1.0, title="Detailer strength")
detailer_resolution: int | None = Field(default=None, ge=256, le=4096, title="Detailer resolution")
detailer_sampler: str | None = Field(default=None, title="Detailer sampler", description="Sampler name for the inpaint pass; a named sampler activates the scheduler overrides below, 'Default' keeps the model scheduler")
detailer_prediction: str | None = Field(default=None, title="Detailer prediction", description="Scheduler prediction type override (default/epsilon/sample/v_prediction/flow_prediction)")
detailer_shift: float | None = Field(default=None, ge=0.0, le=10.0, title="Detailer flow shift", description="Flow/sampler shift for the inpaint pass; needs a named sampler")
detailer_cfg_scale: float | None = Field(default=None, ge=0.0, le=30.0, title="Detailer guidance scale", description="CFG/guidance scale for the inpaint pass")
detailer_loworder: bool | None = Field(default=None, title="Detailer low order")
detailer_thresholding: bool | None = Field(default=None, title="Detailer thresholding")
detailer_dynamic: bool | None = Field(default=None, title="Detailer dynamic shift")
detailer_rescale: bool | None = Field(default=None, title="Detailer rescale betas")
detailer_classes: str | None = Field(default=None, title="Detailer classes", description="Comma-separated class allowlist (e.g. 'face,eye')")
detailer_conf: float | None = Field(default=None, ge=0.0, le=1.0, title="Min confidence")
detailer_iou: float | None = Field(default=None, ge=0.0, le=1.0, title="Max overlap (IoU)")
detailer_max: int | None = Field(default=None, ge=1, title="Max detections")
detailer_min_size: float | None = Field(default=None, ge=0.0, le=1.0, title="Min relative size")
detailer_max_size: float | None = Field(default=None, ge=0.0, le=1.0, title="Max relative size")
detailer_blur: int | None = Field(default=None, ge=0, le=100, title="Mask blur")
detailer_padding: int | None = Field(default=None, ge=0, le=100, title="Mask padding")
detailer_segmentation: bool | None = Field(default=None, title="Use segmentation", description="Use seg-mask instead of bbox (requires a -seg model)")
detailer_merge: bool | None = Field(default=None, title="Merge detections")
detailer_sort: bool | None = Field(default=None, title="Sort detections", description="Sort detections left-to-right for consistency")
detailer_sigma_adjust: float | None = Field(default=None, ge=0.5, le=1.5, title="Renoise sigma")
detailer_sigma_adjust_max: float | None = Field(default=None, ge=0.0, le=1.0, title="Renoise end")
detailer_include_detections: bool | None = Field(default=None, title="Include detections", description="Return annotated debug image alongside the detailed result")
class ResDetail(BaseModel):
image: str = Field(title="Image", description="Detailed image (base64)")
detections: str | None = Field(default=None, title="Detections", description="Annotated debug image (base64) when detailer_include_detections=True")
seed: int = Field(default=-1, title="Seed", description="Effective seed used for the detailer pass")
info: str = Field(default='', title="Info", description="Postprocessing info string")
class ReqImageInfo(BaseModel):
image: str = Field(title="Image", description="The base64 encoded image")
+80 -5
View File
@@ -137,6 +137,80 @@ class APIProcess:
shared.state.end(jobid, api=False)
return ResFace(classes=classes, labels=labels, scores=scores, boxes=boxes, images=images)
def post_detail(self, req: models.ReqDetail):
"""Run the YOLO detailer on a single image as a standalone operation; no base generation pass.
Per-request fields override shared.opts.detailer_* via detailer_opt(p, attr) precedence
in modules/postprocess/yolo.py. Fields left as None fall through to the global setting.
"""
import numpy as np
from PIL import Image
from modules.shared import yolo # pylint: disable=no-name-in-module
if shared.sd_model is None or not hasattr(shared.sd_model, 'sd_checkpoint_info'):
return JSONResponse(status_code=400, content={"error": "no base model selected"})
image = decode_base64_to_image(req.image)
if image is None:
return JSONResponse(status_code=400, content={"error": "invalid image"})
# Per-request overrides for the non-primary detailer fields; None values fall through to opts
# via detailer_opt(p, attr) -> shared.opts.<attr>.
overrides = {attr: getattr(req, attr) for attr in (
'detailer_models', 'detailer_classes', 'detailer_conf',
'detailer_iou', 'detailer_max', 'detailer_min_size',
'detailer_max_size', 'detailer_blur', 'detailer_padding',
'detailer_segmentation', 'detailer_merge', 'detailer_sort',
'detailer_sigma_adjust', 'detailer_sigma_adjust_max',
'detailer_include_detections',
) if getattr(req, attr, None) is not None}
# Sampler block: request field names differ from the p attributes they set, so map explicitly.
# schedulers_* become per-job overrides (need a named sampler to take effect); cfg/sampler apply directly.
for req_attr, p_attr in (
('detailer_sampler', 'hr_sampler_name'),
('detailer_prediction', 'schedulers_prediction_type'),
('detailer_shift', 'schedulers_shift'),
('detailer_cfg_scale', 'cfg_scale'),
('detailer_loworder', 'schedulers_use_loworder'),
('detailer_thresholding', 'schedulers_use_thresholding'),
('detailer_dynamic', 'schedulers_dynamic_shift'),
('detailer_rescale', 'schedulers_rescale_betas'),
):
val = getattr(req, req_attr, None)
if val is not None:
overrides[p_attr] = val
jobid = shared.state.begin('API-DETAIL', api=True)
try:
p = yolo.make_processing(
image,
prompt=req.detailer_prompt or '',
negative=req.detailer_negative or '',
steps=req.detailer_steps if req.detailer_steps is not None else 10,
strength=req.detailer_strength if req.detailer_strength is not None else 0.3,
resolution=req.detailer_resolution if req.detailer_resolution is not None else 1024,
seed=req.seed if req.seed is not None else -1,
overrides=overrides,
)
with self.queue_lock:
result = yolo.restore(np.array(image), p)
annotated_b64 = None
if isinstance(result, list) and len(result) > 0:
out_image = Image.fromarray(result[0])
if len(result) > 1 and result[1] is not None:
annotated = result[1] if isinstance(result[1], Image.Image) else Image.fromarray(result[1])
annotated_b64 = encode_pil_to_base64(annotated)
elif isinstance(result, np.ndarray):
out_image = Image.fromarray(result)
else:
return JSONResponse(status_code=500, content={"error": "detailer produced no result"})
return models.ResDetail(image=encode_pil_to_base64(out_image), detections=annotated_b64, seed=p.all_seeds[0])
finally:
shared.state.end(jobid, api=False)
def post_prompt_enhance(self, req: models.ReqPromptEnhance):
"""Enhance a prompt using an LLM. Supports text, image-conditioned, and video prompt enhancement modes."""
from modules import processing_helpers
@@ -209,23 +283,24 @@ class APIProcess:
def set_upscalers(self, req: dict):
reqDict = vars(req)
script_args = reqDict.pop('script_args', None)
reqDict['extras_upscaler_1'] = reqDict.pop('upscaler_1', None)
reqDict['extras_upscaler_2'] = reqDict.pop('upscaler_2', None)
return reqDict
return reqDict, script_args
def extras_single_image_api(self, req: models.ReqProcessImage):
"""Upscale or postprocess a single image using the configured upscaler pipeline."""
reqDict = self.set_upscalers(req)
reqDict, script_args = self.set_upscalers(req)
reqDict['image'] = helpers.decode_base64_to_image(reqDict['image'])
with self.queue_lock:
result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", save_output=False, **reqDict)
result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict)
return models.ResProcessImage(image=helpers.encode_pil_to_base64(result[0][0]), html_info=result[1])
def extras_batch_images_api(self, req: models.ReqProcessBatch):
"""Upscale or postprocess a batch of images using the configured upscaler pipeline."""
reqDict = self.set_upscalers(req)
reqDict, script_args = self.set_upscalers(req)
image_list = reqDict.pop('imageList', [])
image_folder = [helpers.decode_base64_to_image(x.data) for x in image_list]
with self.queue_lock:
result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", save_output=False, **reqDict)
result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict)
return models.ResProcessBatch(images=list(map(helpers.encode_pil_to_base64, result[0])), html_info=result[1])
+72 -2
View File
@@ -483,6 +483,51 @@ class YoloRestorer(Detailer):
np_images.append(annotated) # save debug image with boxes
return np_images
def make_processing(self, image, prompt='', negative='', steps=10, strength=0.3, resolution=1024, seed=-1, overrides=None):
"""Build a synthetic Img2Img processing object to run restore() standalone, with no base generation pass.
The primary params map to the detailer_* fields restore() reads directly. overrides is an optional
dict of the remaining detailer_* settings; None values are skipped and fall through to shared.opts
via detailer_opt(). The seed is resolved here so restore()'s inpaint passes are reproducible and the
effective value can be reported back.
"""
from modules.processing_helpers import get_fixed_seed
from modules.paths import resolve_output_path
seed = int(get_fixed_seed(seed))
outpath = resolve_output_path(shared.opts.outdir_samples, shared.opts.outdir_extras_samples)
p = processing.StableDiffusionProcessingImg2Img(
sd_model=shared.sd_model,
prompt=prompt or '',
negative_prompt=negative or '',
init_images=[image],
outpath_samples=outpath,
outpath_grids=outpath,
batch_size=1,
n_iter=1,
seed=seed,
width=image.width,
height=image.height,
detailer_enabled=True,
detailer_prompt=prompt or '',
detailer_negative=negative or '',
detailer_steps=steps,
detailer_strength=strength,
detailer_resolution=resolution,
)
for attr, val in (overrides or {}).items():
if val is not None:
setattr(p, attr, val)
# restore() at yolo.py reads all_prompts[0]/all_negative_prompts[0]; the rest avoid AttributeError downstream
p.all_prompts = [p.detailer_prompt or '']
p.all_negative_prompts = [p.detailer_negative or '']
p.all_seeds = [seed]
p.all_subseeds = [-1]
p.scripts = None
p.is_control = False
p.do_not_save_samples = True
p.do_not_save_grid = True
return p
def change_mode(self, dropdown, text):
self.ui_mode = not self.ui_mode
if self.ui_mode:
@@ -533,10 +578,16 @@ class YoloRestorer(Detailer):
ui_mode.click(fn=self.change_mode, inputs=[detailers, detailers_text], outputs=[detailers, detailers_text, refresh_btn])
with gr.Row():
classes = gr.Textbox(label="Detailer classes", placeholder="Classes", elem_id=f"{tab}_detailer_classes")
if tab == 'extras': # Process tab is standalone, there is no base prompt to fall back to
prompt_placeholder = 'detailer prompt, leave empty for none'
negative_placeholder = 'detailer negative prompt, leave empty for none'
else:
prompt_placeholder = 'detailer prompt or leave empty to use main prompt'
negative_placeholder = 'detailer prompt or leave empty to use main prompt'
with gr.Row():
prompt = gr.Textbox(label="Detailer prompt", value='', placeholder='detailer prompt or leave empty to use main prompt', lines=2, elem_id=f"{tab}_detailer_prompt", elem_classes=["prompt"])
prompt = gr.Textbox(label="Detailer prompt", value='', placeholder=prompt_placeholder, lines=2, elem_id=f"{tab}_detailer_prompt", elem_classes=["prompt"])
with gr.Row():
negative = gr.Textbox(label="Detailer negative prompt", value='', placeholder='detailer prompt or leave empty to use main prompt', lines=2, elem_id=f"{tab}_detailer_negative", elem_classes=["prompt"])
negative = gr.Textbox(label="Detailer negative prompt", value='', placeholder=negative_placeholder, lines=2, elem_id=f"{tab}_detailer_negative", elem_classes=["prompt"])
with gr.Row():
steps = gr.Slider(label="Detailer steps", elem_id=f"{tab}_detailer_steps", value=10, minimum=0, maximum=99, step=1)
strength = gr.Slider(label="Detailer strength", elem_id=f"{tab}_detailer_strength", value=0.3, minimum=0, maximum=1, step=0.01)
@@ -557,6 +608,23 @@ class YoloRestorer(Detailer):
with gr.Row(elem_classes=['flex-break']):
renoise_value = gr.Slider(minimum=0.5, maximum=1.5, step=0.01, label='Renoise', value=shared.opts.detailer_sigma_adjust, elem_id=f"{tab}_detailer_renoise")
renoise_end = gr.Slider(minimum=0.0, maximum=1.0, step=0.01, label='Renoise end', value=shared.opts.detailer_sigma_adjust_max, elem_id=f"{tab}_detailer_renoise_end")
sampler_block = None
if tab == 'extras': # fold the standalone sampler settings into the detailer accordion; values applied per-job in make_processing, never global opts
from modules import sd_samplers
sd_samplers.set_samplers()
sampler_choices = [s.name for s in sd_samplers.samplers if s.name != 'Same as primary']
with gr.Accordion('Sampler', open=False, elem_id=f"{tab}_detailer_sampler_accordion", elem_classes=["small-accordion"]):
with gr.Row():
d_sampler = gr.Dropdown(label='Sampling method', choices=sampler_choices, value='Default', elem_id=f"{tab}_detailer_sampler")
d_prediction = gr.Dropdown(label='Prediction method', choices=['default', 'epsilon', 'sample', 'v_prediction', 'flow_prediction'], value='default', elem_id=f"{tab}_detailer_prediction")
with gr.Row():
d_shift = gr.Slider(label='Flow shift', minimum=0, maximum=10, step=0.1, value=shared.opts.schedulers_shift, elem_id=f"{tab}_detailer_shift")
d_cfg = gr.Slider(label='Guidance scale', minimum=0, maximum=30, step=0.1, value=6.0, elem_id=f"{tab}_detailer_cfg")
with gr.Row():
d_options = gr.CheckboxGroup(label='Options', choices=['low order', 'thresholding', 'dynamic', 'rescale'], value=['low order'], elem_id=f"{tab}_detailer_options")
with gr.Row():
d_seed = gr.Number(label='Seed', value=-1, precision=0, elem_id=f"{tab}_detailer_seed")
sampler_block = {'sampler': d_sampler, 'prediction': d_prediction, 'shift': d_shift, 'cfg_scale': d_cfg, 'options': d_options, 'seed': d_seed}
merge.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
detailers.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
@@ -573,6 +641,8 @@ class YoloRestorer(Detailer):
save.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
sort.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
seg.change(fn=ui_settings_change, inputs=[merge, detailers, detailers_text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg], outputs=[])
if tab == 'extras':
return enabled, prompt, negative, steps, strength, resolution, sampler_block
return enabled, prompt, negative, steps, strength, resolution
+7 -3
View File
@@ -100,10 +100,10 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp
return outputs, info, params
def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True):
def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True, script_args: dict | None = None):
"""old handler for API"""
args = scripts_manager.scripts_postproc.create_args_for_run({
merged = {
"Upscale": {
"upscale_mode": resize_mode,
"upscale_by": upscaling_resize,
@@ -114,6 +114,10 @@ def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_
"upscaler_2_name": extras_upscaler_2,
"upscaler_2_visibility": extras_upscaler_2_visibility,
},
})
}
if script_args:
for name, kvs in script_args.items():
merged.setdefault(name, {}).update(kvs or {})
args = scripts_manager.scripts_postproc.create_args_for_run(merged)
return run_postprocessing(extras_mode, image, image_folder, input_dir, output_dir, show_extras_results, *args, save_output=save_output)
+3 -3
View File
@@ -118,11 +118,11 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
args["Hires steps"] = p.hr_second_pass_steps
args["Hires strength"] = p.hr_denoising_strength
args["Hires sampler"] = p.hr_sampler_name if p.hr_sampler_name != 'Default' else None
args["Hires CFG scale"] = p.cfg_image if p.cfg_image > -1 else None
args["Hires CFG scale"] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
if 'refine' in p.ops:
args["Refine"] = p.enable_hr
args["Refiner"] = None if (not shared.opts.add_model_name_to_info) or (not shared.sd_refiner) or (not shared.sd_refiner.sd_checkpoint_info.model_name) else shared.sd_refiner.sd_checkpoint_info.model_name.replace(',', '').replace(':', '')
args['Hires CFG scale'] = p.cfg_image if p.cfg_image > -1 else None
args['Hires CFG scale'] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
args['Refiner steps'] = p.refiner_steps
args['Refiner start'] = p.refiner_start
args["Hires steps"] = p.hr_second_pass_steps
@@ -130,7 +130,7 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
if ('img2img' in p.ops or 'inpaint' in p.ops) and ('txt2img' not in p.ops and 'hires' not in p.ops): # real img2img/inpaint
args["Init image size"] = f"{getattr(p, 'init_img_width', 0)}x{getattr(p, 'init_img_height', 0)}"
args["Init image hash"] = getattr(p, 'init_img_hash', None)
args['Image CFG scale'] = p.cfg_image if p.cfg_image > -1 else None
args['Image CFG scale'] = p.cfg_image if (p.cfg_image is not None and p.cfg_image > -1) else None
args["Mask weight"] = getattr(p, "inpainting_mask_weight", shared.opts.inpainting_mask_weight) if p.is_using_inpainting_conditioning else None
args["Denoising strength"] = getattr(p, 'denoising_strength', None)
if args["Size"] != args["Init image size"]: