mirror of
https://github.com/vladmandic/automatic
synced 2026-09-20 01:31:13 +02:00
feat(api): add detailer postprocess script and /sdapi/v1/detail endpoint
Surface YoloRestorer.restore() as a standalone operation: a Detailer
postprocessing script in the Process tab and a thin /sdapi/v1/detail
endpoint, neither requiring a base generation pass.
- modules/postprocess/yolo.py: YoloRestorer.make_processing() builds the
synthetic Img2Img processing object both entry points feed to restore(),
resolving the seed so the inpaint passes are reproducible
- modules/api/process.py: post_detail handler exposes the full detailer
parameter set and returns the detailed image plus optional annotations
as base64
- scripts/postprocessing_detailer.py: reuses shared.yolo.ui('extras') and
runs through make_processing()
- modules/postprocessing.py: run_extras takes a per-script script_args
dict, also letting the extras API drive other scripts such as Remove
background; omitting it leaves existing callers unchanged
- modules/api/models.py: ReqDetail / ResDetail
- modules/processing_info.py: guard create_infotext's Image/Hires CFG
reporting against an unset (None) cfg_image, matching the is-not-None
checks the other cfg_image readers use; the detailer inpaint pass runs
with it unset
- test/test-detailer-api.py: covers both paths; effect tests measure the
diff inside the detected region with extreme isolated parameter values,
and the suite disables model quantization for the run and restores the
original settings afterward
This commit is contained in:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user