add xyz and script support to control api

Signed-off-by: vladmandic <mandic00@live.com>
This commit is contained in:
vladmandic
2025-11-23 13:07:42 -05:00
parent 7de58d8567
commit b5f000ab8a
13 changed files with 232 additions and 46 deletions
+8 -3
View File
@@ -1,12 +1,12 @@
# Change Log for SD.Next
## Update for 2025-11-22
## Update for 2025-11-23
### TBD
Merge commit: `f903a36d9`
### Highlights for 2025-11-22
### Highlights for 2025-11-23
New native [kanvas](https://vladmandic.github.io/sdnext-docs/Kanvas/) module for image manipulation that fully replaces img2img, inpaint and outpaint controls
And a first cloud model with **Google's Nano Banana** *2.5 Flash and 3.0 Pro* plus new **Photoroom PRX** model
@@ -15,7 +15,7 @@ And a first cloud model with **Google's Nano Banana** *2.5 Flash and 3.0 Pro* pl
[ReadMe](https://github.com/vladmandic/automatic/blob/master/README.md) | [ChangeLog](https://github.com/vladmandic/automatic/blob/master/CHANGELOG.md) | [Docs](https://vladmandic.github.io/sdnext-docs/) | [WiKi](https://github.com/vladmandic/automatic/wiki) | [Discord](https://discord.com/invite/sd-next-federal-batch-inspectors-1101998836328697867) | [Sponsor](https://github.com/sponsors/vladmandic)
### Details for 2025-11-22
### Details for 2025-11-23
- **Models**
- **Google Gemini Nano Banana** [2.5 Flash](https://blog.google/products/gemini/gemini-nano-banana-examples/) and [3.0 Pro](https://deepmind.google/models/gemini-image/pro/)
@@ -36,6 +36,10 @@ And a first cloud model with **Google's Nano Banana** *2.5 Flash and 3.0 Pro* pl
- **amdgpu**: prefer rocm-on-windows over zluda
- **amdgpu**: improve rocm-on-windows installer
- **sdnq**: improve dequant logic
- **API**
- `/control` endpoint is now fully compatible with scripts
- `/control` additional params to to control *xyz grid*
see `cli/api-xyz.py` for simple example
- **Internal**
- sdnq: multiple improvements to quantization and dequantization logic
- torch: update to `torch==2.9.1` for *cuda, ipex, openvino, rocm* backends
@@ -51,6 +55,7 @@ And a first cloud model with **Google's Nano Banana** *2.5 Flash and 3.0 Pro* pl
- sdnq: unconditional register on startup
- python: start work on future-proofing for modern python versions, thanks @awsr
- nunchaku: update to `1.0.2`
- lint: add rules for run-on-windows
- **Fixes**
- xyz-grid: improve parsing of axis lists, thanks @awsr
- hires: strength save/load in metadata, thanks @awsr
-1
View File
@@ -101,7 +101,6 @@ Anything marked with **(!!!)** means a change *will* eventually be required.
> npm run todo
- control: support scripts via api
- fc: autodetect distilled based on model
- fc: autodetect tensor format based on model
- hypertile: vae breaks when using non-standard sizes
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python
# example: api-control.py --prompt "anime girl" --control "Canny:Canny:1.0:0.1:0.9:/home/vlado/generative/Samples/anime1.jpg,None:Depth:0.9:0.0:1.0:/home/vlado/generative/Samples/anime1.jpg" --hires --detailer --output /tmp/anime.jpg
import os
import io
import time
import base64
import logging
import argparse
import requests
import urllib3
from PIL import Image
sd_url = os.environ.get('SDAPI_URL', "http://127.0.0.1:7860")
sd_username = os.environ.get('SDAPI_USR', None)
sd_password = os.environ.get('SDAPI_PWD', None)
logging.basicConfig(level = logging.INFO, format = '%(asctime)s %(levelname)s: %(message)s')
log = logging.getLogger(__name__)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
options = {
"save_images": False,
"send_images": True,
}
def auth():
if sd_username is not None and sd_password is not None:
return requests.auth.HTTPBasicAuth(sd_username, sd_password)
return None
def post(endpoint: str, dct: dict = None):
req = requests.post(f'{sd_url}{endpoint}', json = dct, timeout=300, verify=False, auth=auth())
if req.status_code != 200:
return { 'error': req.status_code, 'reason': req.reason, 'url': req.url }
else:
return req.json()
def generate(args): # pylint: disable=redefined-outer-name
t0 = time.time()
options['prompt'] = args.prompt
options['negative_prompt'] = args.negative
options['xyz'] = {
'draw_legend': args.legend,
'include_grid': args.grid,
'include_subgrids': args.subgrids,
'include_images': args.images,
'include_time': args.time,
'include_text': args.text,
}
if args.x_type and args.x_values:
options['xyz']['x_type'] = args.x_type
options['xyz']['x_values'] = args.x_values
if args.y_type and args.y_values:
options['xyz']['y_type'] = args.y_type
options['xyz']['y_values'] = args.y_values
if args.z_type and args.z_values:
options['xyz']['z_type'] = args.z_type
options['xyz']['z_values'] = args.z_values
data = post('/sdapi/v1/control', options)
t1 = time.time()
if 'info' in data:
log.info(f'info: {data["info"]}')
def get_image(encoded, output):
if not isinstance(encoded, list):
return
for i in range(len(encoded)):
b64 = encoded[i].split(',',1)[0]
info = data['info']
image = Image.open(io.BytesIO(base64.b64decode(b64)))
log.info(f'received image: size={image.size} time={t1-t0:.2f} info="{info}"')
if output:
image.save(output)
log.info(f'image saved: size={image.size} filename={output}')
if 'images' in data:
get_image(data['images'], args.output)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description = 'api-control')
parser.add_argument('--output', required=False, default=None, help='output filename')
parser.add_argument('--prompt', required=False, default='', help='prompt text')
parser.add_argument('--negative', required=False, default='', help='negative prompt text')
parser.add_argument('--x-type', required=False, default=None, help='x axis type')
parser.add_argument('--y-type', required=False, default=None, help='y axis type')
parser.add_argument('--z-type', required=False, default=None, help='z axis type')
parser.add_argument('--x-values', required=False, default=None, help='x axis values')
parser.add_argument('--y-values', required=False, default=None, help='y axis values')
parser.add_argument('--z-values', required=False, default=None, help='z axis values')
parser.add_argument('--legend', required=False, default=True, help='Draw legend')
parser.add_argument('--grid', required=False, default=True, help='Include grid')
parser.add_argument('--subgrids', required=False, default=False, help='Include subgrids')
parser.add_argument('--images', required=False, default=True, help='Include images')
parser.add_argument('--time', required=False, default=True, help='Include time')
parser.add_argument('--text', required=False, default=True, help='Include text')
args = parser.parse_args()
log.info(f'api-control: {args}')
generate(args)
+1 -1
View File
@@ -755,7 +755,7 @@ def install_rocm_zluda():
zluda_installer.load()
except Exception as e:
log.warning(f'Failed to load ZLUDA: {e}')
else: # TODO install: switch to pytorch source when it becomes available
else: # TODO rocm: switch to pytorch source when it becomes available
if device is None:
log.warning('No ROCm agent was found. Please make sure that graphics driver is installed and up to date.')
if isinstance(rocm.environment, rocm.PythonPackageEnvironment):
+47 -7
View File
@@ -18,6 +18,21 @@ class ItemControl(BaseModel):
override: str = Field(title="Override image", default=None, description="")
class ItemXYZ(BaseModel):
x_type: str = Field(title="X axis values", default='')
x_values: str = Field(title="X axis values", default='')
y_type: str = Field(title="Y axis values", default='')
y_values: str = Field(title="Y axis values", default='')
z_type: str = Field(title="Z axis values", default='')
z_values: str = Field(title="Z axis values", default='')
draw_legend: bool = Field(title="Draw legend", default=True)
include_grid: bool = Field(title="Include grid", default=True)
include_subgrids: bool = Field(title="Include subgrids", default=False)
include_images: bool = Field(title="Include images", default=False)
include_time: bool = Field(title="Include time", default=False)
include_text: bool = Field(title="Include text", default=False)
ReqControl = models.create_model_from_signature(
func = run.control_run,
model_name = "StableDiffusionProcessingControl",
@@ -31,6 +46,7 @@ ReqControl = models.create_model_from_signature(
{"key": "ip_adapter", "type": Optional[List[models.ItemIPAdapter]], "default": None, "exclude": True},
{"key": "face", "type": Optional[models.ItemFace], "default": None, "exclude": True},
{"key": "control", "type": Optional[List[ItemControl]], "default": [], "exclude": True},
{"key": "xyz", "type": Optional[ItemXYZ], "default": None, "exclude": True},
# {"key": "extra", "type": Optional[dict], "default": {}, "exclude": True},
]
)
@@ -54,13 +70,13 @@ class APIControl():
def sanitize_args(self, args: dict):
args = vars(args)
args.pop('sampler_name', None)
args.pop('script_name', None)
args.pop('script_args', None) # will refeed them to the pipeline directly after initializing them
args.pop('alwayson_scripts', None)
args.pop('face', None)
args.pop('face_id', None)
args.pop('ip_adapter', None)
args.pop('save_images', None)
args['override_script_name'] = args.pop('script_name', None)
args['override_script_args'] = args.pop('script_args', None)
return args
def sanitize_b64(self, request):
@@ -75,6 +91,8 @@ class APIControl():
sanitize_str(script_obj["args"])
if hasattr(request, "script_args") and request.script_args:
sanitize_str(request.script_args)
if hasattr(request, 'override_script_args') and request.override_script_args:
request.pop('override_script_args', None)
def prepare_face_module(self, req):
if hasattr(req, "face") and req.face and not req.script_name and (not req.alwayson_scripts or "face" not in req.alwayson_scripts.keys()):
@@ -97,6 +115,21 @@ class APIControl():
]
del req.face
def prepare_xyz_grid(self, req):
if hasattr(req, "xyz") and req.xyz:
req.script_name = "xyz grid"
req.script_args = [
req.xyz.x_type, req.xyz.x_values, '',
req.xyz.y_type, req.xyz.y_values, '',
req.xyz.z_type, req.xyz.z_values, '',
False, # csv_mode
req.xyz.draw_legend,
False, # no_fixed_seeds
req.xyz.include_grid, req.xyz.include_subgrids, req.xyz.include_images,
req.xyz.include_time, req.xyz.include_text,
]
del req.xyz
def prepare_ip_adapter(self, request):
if hasattr(request, "ip_adapter") and request.ip_adapter:
args = { 'ip_adapter_names': [], 'ip_adapter_scales': [], 'ip_adapter_crops': [], 'ip_adapter_starts': [], 'ip_adapter_ends': [], 'ip_adapter_images': [], 'ip_adapter_masks': [] }
@@ -150,18 +183,22 @@ class APIControl():
del req.control
def post_control(self, req: ReqControl):
self.prepare_face_module(req)
requested = req.control
self.prepare_face_module(req)
self.prepare_control(req)
self.prepare_xyz_grid(req)
# prepare scripts
# prepare args
args = req.copy(update={ # Override __init__ params
args = req.copy(update={ # Override __init__ params
"sampler_index": processing_helpers.get_sampler_index(req.sampler_name),
"is_generator": True,
"inputs": [helpers.decode_base64_to_image(x) for x in req.inputs] if req.inputs else None,
"inits": [helpers.decode_base64_to_image(x) for x in req.inits] if req.inits else None,
"mask": helpers.decode_base64_to_image(req.mask) if req.mask else None,
})
args = self.sanitize_args(args)
send_images = args.pop('send_images', True)
@@ -171,10 +208,13 @@ class APIControl():
output_images = []
output_processed = []
output_info = ''
# TODO control: support scripts via api
# init script args, call scripts.script_control.run, call scripts.script_control.after
run.control_set({ 'do_not_save_grid': not req.save_images, 'do_not_save_samples': not req.save_images, **self.prepare_ip_adapter(req) })
run.control_set({
'do_not_save_grid': not req.save_images,
'do_not_save_samples': not req.save_images,
**self.prepare_ip_adapter(req),
})
run.control_set(getattr(req, "extra", {}))
# run
res = run.control_run(**args)
for item in res:
if len(item) > 0 and (isinstance(item[0], list) or item[0] is None): # output_images
+2
View File
@@ -70,6 +70,8 @@ def save_image(image, fn, ext):
image = image.convert("RGB")
elif image.mode == 'I;16':
image = image.point(lambda p: p * 0.0038910505836576).convert("L")
elif image.mode == 'P':
image = image.convert("RGB")
exif_bytes = piexif.dump({ "Exif": { piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(parameters or "", encoding="unicode") } })
image.save(fn, format=image_format, quality=shared.opts.jpeg_quality, exif=exif_bytes)
elif image_format == 'WEBP':
+1 -1
View File
@@ -442,7 +442,7 @@ class ResGPU(BaseModel): # definition of http response
# helper function
def create_model_from_signature(func: Callable, model_name: str, base_model: Type[BaseModel] = BaseModel, additional_fields: List = [], exclude_fields: List[str] = []):
def create_model_from_signature(func: Callable, model_name: str, base_model: Type[BaseModel] = BaseModel, additional_fields: List = [], exclude_fields: List[str] = []) -> type[BaseModel]:
from PIL import Image
class Config:
+13 -4
View File
@@ -289,6 +289,8 @@ def control_run(state: str = '', # pylint: disable=keyword-arg-before-vararg
hr_scale: float = 1.0, hr_resize_x: int = 0, hr_resize_y: int = 0, refiner_steps: int = 5, refiner_start: float = 0.0, refiner_prompt: str = '', refiner_negative: str = '',
video_skip_frames: int = 0, video_type: str = 'None', video_duration: float = 2.0, video_loop: bool = False, video_pad: int = 0, video_interpolate: int = 0,
extra: dict = {},
override_script_name: str = None,
override_script_args = [],
*input_script_args,
):
global pipe, original_pipeline # pylint: disable=global-statement
@@ -589,10 +591,17 @@ def control_run(state: str = '', # pylint: disable=keyword-arg-before-vararg
p.scripts = scripts_manager.scripts_control
p.script_args = input_script_args or []
if len(p.script_args) == 0:
script_runner = scripts_manager.scripts_control
if not script_runner.scripts:
script_runner.initialize_scripts(False)
p.script_args = script.init_default_script_args(script_runner)
if not p.scripts:
p.scripts.initialize_scripts(False)
p.script_args = script.init_default_script_args(p.scripts)
# init override scripts
if override_script_name and override_script_args:
selectable_scripts, selectable_script_idx = script.get_selectable_script(override_script_name, p.scripts)
if selectable_scripts:
for idx in range(len(override_script_args)):
p.script_args[selectable_scripts.args_from + idx] = override_script_args[idx]
p.script_args[0] = selectable_script_idx + 1
# actual processing
processed: processing.Processed = None
+2 -2
View File
@@ -73,9 +73,9 @@ class FilenameGenerator:
if seed is not None and int(seed) > 0:
self.seed = seed
elif p is not None and getattr(p, 'all_seeds', None) is not None and len(p.all_seeds) > 0:
self.seed = p.all_seeds[0] if int(p.all_seeds[0]) > 0 else 0
self.seed = p.all_seeds[0] if p.all_seeds[0] is not None and int(p.all_seeds[0]) > 0 else 0
elif p is not None and getattr(p, 'seeds', None) is not None and len(p.seeds) > 0:
self.seed = p.seeds[0] if int(p.seeds[0]) > 0 else 0
self.seed = p.seeds[0] if p.seeds[0] is not None and int(p.seeds[0]) > 0 else 0
else:
self.seed = p.seed if p is not None and getattr(p, 'seed', 0) > 0 else 0
if prompt is not None:
+1
View File
@@ -178,6 +178,7 @@ def process_base(p: processing.StableDiffusionProcessing):
else:
taskid = shared.state.begin('Inference')
output = shared.sd_model(**base_args)
output = None
shared.state.end(taskid)
if isinstance(output, dict):
output = SimpleNamespace(**output)
+5
View File
@@ -26,6 +26,11 @@
"format": ". venv/bin/activate && pre-commit run --all-files",
"lint": "npm run eslint && npm run format && npm run ruff && npm run pylint | grep -v TODO",
"todo": "npm run pylint | grep W0511 | awk -F'TODO ' '{print \"- \"$NF}' | sed 's/ (fixme)//g' | sort",
"eslint-win": "eslint . javascript/ --rule \"linebreak-style: off\"",
"ruff-win": "venv\\scripts\\activate && ruff check",
"pylint-win": "venv\\scripts\\activate && pylint *.py modules/ pipelines/ scripts/ extensions-builtin/",
"format-win": "venv\\scripts\\activate && pre-commit run --all-files",
"lint-win": "npm run eslint-win && npm run format-win && npm run ruff-win && npm run pylint-win",
"test": ". venv/bin/activate; python launch.py --debug --test"
},
"devDependencies": {
+23 -13
View File
@@ -218,21 +218,31 @@ class Script(scripts_manager.Script):
opt.confirm(p, valslist)
return valslist
def parse_axis(x_type, x_values, x_values_dropdown):
x_opt = None
if isinstance(x_type, str):
x_opt = [o for o in self.current_axis_options if o.label.lower() == x_type.lower()]
if len(x_opt) == 0:
x_opt = [o for o in self.current_axis_options if x_type.lower() in o.label.lower()]
if len(x_opt) > 0:
x_opt = x_opt[0]
else:
x_opt = self.current_axis_options[x_type]
if x_opt:
if x_opt.choices is not None and not csv_mode:
x_values = list_to_csv_string(x_values_dropdown)
xs = process_axis(x_opt, x_values, x_values_dropdown)
else:
xs = []
return x_opt, xs
try:
x_opt = self.current_axis_options[x_type]
if x_opt.choices is not None and not csv_mode:
x_values = list_to_csv_string(x_values_dropdown)
xs = process_axis(x_opt, x_values, x_values_dropdown)
y_opt = self.current_axis_options[y_type]
if y_opt.choices is not None and not csv_mode:
y_values = list_to_csv_string(y_values_dropdown)
ys = process_axis(y_opt, y_values, y_values_dropdown)
z_opt = self.current_axis_options[z_type]
if z_opt.choices is not None and not csv_mode:
z_values = list_to_csv_string(z_values_dropdown)
zs = process_axis(z_opt, z_values, z_values_dropdown)
x_opt, xs = parse_axis(x_type, x_values, x_values_dropdown)
y_opt, ys = parse_axis(y_type, y_values, y_values_dropdown)
z_opt, zs = parse_axis(z_type, z_values, z_values_dropdown)
except Exception as e:
shared.log.error(f"XYZ grid: invalid axis values {e}")
errors.display(e, 'xyz')
return None
Image.MAX_IMAGE_PIXELS = None # disable check in Pillow and rely on check below to allow large custom image sizes
@@ -274,7 +284,7 @@ class Script(scripts_manager.Script):
shared.state.update('Grid', total_steps, total_jobs * p.n_iter)
image_cell_count = p.n_iter * p.batch_size
shared.log.info(f"XYZ grid: images={len(xs)*len(ys)*len(zs)*image_cell_count} grid={len(zs)} shape={len(xs)}x{len(ys)} cells={len(zs)} steps={total_steps}")
shared.log.info(f"XYZ grid start: images={len(xs)*len(ys)*len(zs)*image_cell_count} grid={len(zs)} shape={len(xs)}x{len(ys)} cells={len(zs)} steps={total_steps} csv={csv_mode} legend={draw_legend} grid={include_grid} subgrid={include_subgrids} images={include_images} time={include_time} text={include_text}")
AxisInfo = namedtuple('AxisInfo', ['axis', 'values'])
shared.state.xyz_plot_x = AxisInfo(x_opt, xs)
shared.state.xyz_plot_y = AxisInfo(y_opt, ys)
+23 -14
View File
@@ -233,22 +233,31 @@ class Script(scripts_manager.Script):
opt.confirm(p, valslist)
return valslist
def parse_axis(x_type, x_values, x_values_dropdown):
x_opt = None
if isinstance(x_type, str):
x_opt = [o for o in self.current_axis_options if o.label.lower() == x_type.lower()]
if len(x_opt) == 0:
x_opt = [o for o in self.current_axis_options if x_type.lower() in o.label.lower()]
if len(x_opt) > 0:
x_opt = x_opt[0]
else:
x_opt = self.current_axis_options[x_type]
if x_opt:
if x_opt.choices is not None and not csv_mode:
x_values = list_to_csv_string(x_values_dropdown)
xs = process_axis(x_opt, x_values, x_values_dropdown)
else:
xs = []
return x_opt, xs
try:
x_opt = self.current_axis_options[x_type]
if x_opt.choices is not None and not csv_mode:
x_values = list_to_csv_string(x_values_dropdown)
xs = process_axis(x_opt, x_values, x_values_dropdown)
y_opt = self.current_axis_options[y_type]
if y_opt.choices is not None and not csv_mode:
y_values = list_to_csv_string(y_values_dropdown)
ys = process_axis(y_opt, y_values, y_values_dropdown)
z_opt = self.current_axis_options[z_type]
if z_opt.choices is not None and not csv_mode:
z_values = list_to_csv_string(z_values_dropdown)
zs = process_axis(z_opt, z_values, z_values_dropdown)
x_opt, xs = parse_axis(x_type, x_values, x_values_dropdown)
y_opt, ys = parse_axis(y_type, y_values, y_values_dropdown)
z_opt, zs = parse_axis(z_type, z_values, z_values_dropdown)
except Exception as e:
shared.log.error(f"XYZ grid: invalid axis values {e}")
active = False
errors.display(e, 'xyz')
return None
Image.MAX_IMAGE_PIXELS = None # disable check in Pillow and rely on check below to allow large custom image sizes
@@ -292,7 +301,7 @@ class Script(scripts_manager.Script):
shared.state.update('Grid', total_steps, total_jobs)
image_cell_count = p.n_iter * p.batch_size
shared.log.info(f"XYZ grid start: images={len(xs)*len(ys)*len(zs)*image_cell_count} grid={len(zs)} shape={len(xs)}x{len(ys)} cells={len(zs)} steps={total_steps}")
shared.log.info(f"XYZ grid start: images={len(xs)*len(ys)*len(zs)*image_cell_count} grid={len(zs)} shape={len(xs)}x{len(ys)} cells={len(zs)} steps={total_steps} csv={csv_mode} legend={draw_legend} grid={include_grid} subgrid={include_subgrids} images={include_images} time={include_time} text={include_text}")
AxisInfo = namedtuple('AxisInfo', ['axis', 'values'])
shared.state.xyz_plot_x = AxisInfo(x_opt, xs)
shared.state.xyz_plot_y = AxisInfo(y_opt, ys)