From b5f000ab8ab9fca56e331bfe29e5391956c889a4 Mon Sep 17 00:00:00 2001 From: vladmandic Date: Sun, 23 Nov 2025 13:07:42 -0500 Subject: [PATCH] add xyz and script support to control api Signed-off-by: vladmandic --- CHANGELOG.md | 11 +++- TODO.md | 1 - cli/api-xyz.py | 106 ++++++++++++++++++++++++++++++++ installer.py | 2 +- modules/api/control.py | 54 +++++++++++++--- modules/api/helpers.py | 2 + modules/api/models.py | 2 +- modules/control/run.py | 17 +++-- modules/images_namegen.py | 4 +- modules/processing_diffusers.py | 1 + package.json | 5 ++ scripts/xyz_grid.py | 36 +++++++---- scripts/xyz_grid_on.py | 37 ++++++----- 13 files changed, 232 insertions(+), 46 deletions(-) create mode 100644 cli/api-xyz.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a60d7cb..7fbbc3c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TODO.md b/TODO.md index b2910e114..5a4c6519e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/cli/api-xyz.py b/cli/api-xyz.py new file mode 100644 index 000000000..e82fa23a8 --- /dev/null +++ b/cli/api-xyz.py @@ -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) diff --git a/installer.py b/installer.py index 3812067cc..d75ab3f77 100644 --- a/installer.py +++ b/installer.py @@ -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): diff --git a/modules/api/control.py b/modules/api/control.py index bc7e1efa9..63b7636dd 100644 --- a/modules/api/control.py +++ b/modules/api/control.py @@ -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 diff --git a/modules/api/helpers.py b/modules/api/helpers.py index 15b7e29aa..a686e6bec 100644 --- a/modules/api/helpers.py +++ b/modules/api/helpers.py @@ -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': diff --git a/modules/api/models.py b/modules/api/models.py index 61d3dff66..fa6ef0e15 100644 --- a/modules/api/models.py +++ b/modules/api/models.py @@ -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: diff --git a/modules/control/run.py b/modules/control/run.py index b33a2774d..e05aa65af 100644 --- a/modules/control/run.py +++ b/modules/control/run.py @@ -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 diff --git a/modules/images_namegen.py b/modules/images_namegen.py index 7382462b8..efc490d62 100644 --- a/modules/images_namegen.py +++ b/modules/images_namegen.py @@ -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: diff --git a/modules/processing_diffusers.py b/modules/processing_diffusers.py index 1da33d732..f9b552fd0 100644 --- a/modules/processing_diffusers.py +++ b/modules/processing_diffusers.py @@ -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) diff --git a/package.json b/package.json index 40893ef1f..cfbf92e6a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/xyz_grid.py b/scripts/xyz_grid.py index dee43303d..1bd51d232 100644 --- a/scripts/xyz_grid.py +++ b/scripts/xyz_grid.py @@ -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) diff --git a/scripts/xyz_grid_on.py b/scripts/xyz_grid_on.py index 0eee4c548..99bc89fa3 100644 --- a/scripts/xyz_grid_on.py +++ b/scripts/xyz_grid_on.py @@ -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)