diff --git a/.gitignore b/.gitignore index ecb40a8..a92a497 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ venv *.iml *.local .idea -outputs/* \ No newline at end of file +outputs/* +**/__pycache__ +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index dcec9f3..5df202c 100644 --- a/README.md +++ b/README.md @@ -5,47 +5,48 @@ the canvas when the image is generated. ## Controls -| Key / Mouse button | Control | -|-------------------------------|------------------------------------------------------------| -| Left button | Draw with the current brush size | -| Middle button | Draw with a white color brush | -| `e` + Left button | Eraser brush (bigger) | -| Scroll up / down | Increase / decrease brush size | -| `backspace` | Erase the entire sketch | -| `shift` + Left button | Draw a line between two clicks | -| `RETURN` or `ENTER` | Request image rendering | -| `ctrl` + `i` | Interrupt image rendering | -| `c` | Display current configuration while pressed | -| `p` | Edit prompt | -| `alt` + `p` | Edit negative prompt | -| `a` | Toggle autosave | -| `shift` + `t` | Cycle render wait time (+0.5s, or off) | -| `ctrl` + `p` | Pause dynamic rendering | -| `q` | Toggle quick rendering : low steps & HR fix off | -| `n` | Random seed value | -| `ctrl` + `n` | Edit seed value | -| `UP` / `DOWN` | Increase / decrease seed by 1 | -| `ctrl`+ `s` | Save the current generated image | -| `ctrl`+ `o` | Open an image file as sketch | -| `ctrl`+ `d` | Call ControlNet detector, cycle detectors (replace sketch) | -| `h` | Toggle HR fix | -| `shift` + `h` | Cycle HR fix scale | -| `shift` + `u` | Cycle HR upscalers | -| `shift` + `d` | Cycle denoising strengths | -| `shift` + `s` | Cycle samplers | -| `b` | Toggle batch rendering | -| `shift` + `b` | Cycle batch sizes | -| `shift` + `c` | Cycle CLIP skip settings | -| `shift` + `m` | Cycle ControlNel models | -| `shift` + `w` | Cycle ControlNel weights | -| `shift` + `g` | Cycle ControlNel guidance ends | -| `shift` + `ctrl` + `g` | Toggle ControlNel pixel perfect mode | -| `keypad 0` | Restore starting settings | -| `keypad 1-9` | Load custom rendering preset | -| `ctrl` + `keypad 1-9` | Save custom rendering preset | -| `alt` + `keypad 1-9` | Load custom ControlNet preset | -| `ctrl` + `alt` + `keypad 1-9` | Save custom ControlNet preset | -| `x` or `ESC` | Quit | +| Key / Mouse button | Control | +|-------------------------------|-------------------------------------------------| +| Left button | Draw with the current brush size | +| Middle button | Draw with a white color brush | +| `e` + Left button | Eraser brush (bigger) | +| Scroll up / down | Increase / decrease brush size | +| `backspace` | Erase the entire sketch | +| `shift` + Left button | Draw a line between two clicks | +| `RETURN` or `ENTER` | Request image rendering | +| `ctrl` + `i` | Interrupt image rendering | +| `c` | Display current configuration while pressed | +| `p` | Edit prompt | +| `alt` + `p` | Edit negative prompt | +| `a` | Toggle autosave | +| `shift` + `t` | Cycle render wait time (+0.5s, or off) | +| `ctrl` + `p` | Pause dynamic rendering | +| `q` | Toggle quick rendering : low steps & HR fix off | +| `n` | Random seed value | +| `ctrl` + `n` | Edit seed value | +| `UP` / `DOWN` | Increase / decrease seed by 1 | +| `ctrl`+ `s` | Save the current generated image | +| `ctrl`+ `o` | Open an image file as sketch | +| `ctrl`+ `d` | Call ControlNet detector (replace sketch) | +| `shift` + `ctrl`+ `d` | Cycle ControlNet detectors | +| `h` | Toggle HR fix | +| `shift` + `h` | Cycle HR fix scale | +| `shift` + `u` | Cycle HR upscalers | +| `shift` + `d` | Cycle denoising strengths | +| `shift` + `s` | Cycle samplers | +| `b` | Toggle batch rendering | +| `shift` + `b` | Cycle batch sizes | +| `shift` + `c` | Cycle CLIP skip settings | +| `shift` + `m` | Cycle ControlNel models | +| `shift` + `w` | Cycle ControlNel weights | +| `shift` + `g` | Cycle ControlNel guidance ends | +| `shift` + `ctrl` + `g` | Toggle ControlNel pixel perfect mode | +| `keypad 0` | Restore starting settings | +| `keypad 1-9` | Load custom rendering preset | +| `ctrl` + `keypad 1-9` | Save custom rendering preset | +| `alt` + `keypad 1-9` | Load custom ControlNet preset | +| `ctrl` + `alt` + `keypad 1-9` | Save custom ControlNet preset | +| `x` or `ESC` | Quit | _Note_ : "Cycle" shortcuts type will wait for the `shift` key to be released before launching the rendering. diff --git a/Scripts/SdPaint.py b/Scripts/SdPaint.py deleted file mode 100644 index 098cef7..0000000 --- a/Scripts/SdPaint.py +++ /dev/null @@ -1,1821 +0,0 @@ -import copy -import functools -import gc -import os -import random -import re -import shutil -import sys - -import pygame -import pygame.gfxdraw -import requests -import threading -# import numpy as np -import base64 -import io -import json -import time -import math -from PIL import Image, ImageOps -from psd_tools import PSDImage -import tkinter as tk -from tkinter import filedialog, simpledialog -import argparse - -# Initialize Pygame -pygame.init() -clock = pygame.time.Clock() - - -def load_config(config_file): - """ - Load a configuration file, update the local configuration file with missing settings - from distribution file if needed. - :param str config_file: The configuration file name. - :return: The configuration file content. - """ - - if not os.path.exists(config_file): - shutil.copy(f"{config_file}-dist", config_file) - - with open(config_file, "r") as f: - config_content: dict = json.load(f) - - with open(f"{config_file}-dist", "r") as f: - config_dist_content: dict = json.load(f) - - # Update local config with new settings - if isinstance(config_content, dict): - if config_content.keys() != config_dist_content.keys(): - config_dist_content.update(config_content) - config_content = config_dist_content - - print(f"Updated {config_file} with new settings.") - - with open(config_file, "w") as f: - json.dump(config_content, f, indent=4) - - return config_content - - -def update_config(config_file, write=False, values=None): - """ - Update configuration, overwriting given fields. Optionally save the configuration to local file. - :param str config_file: The configuration file name. - :param bool write: Write the configuration file on disk. - :param dict values: The arguments to overwrite. - :return: - """ - if not os.path.exists(config_file): - shutil.copy(f"{config_file}-dist", config_file) - - with open(config_file, "r") as f: - config_content: dict = json.load(f) - - if values: - config_content.update(values) - - if write: - with open(config_file, "w") as f: - json.dump(config_content, f, indent=4) - - return config_content - - -# Read JSON main configuration file -config_file = "config.json" -config = load_config(config_file) - -presets_file = "presets.json" -presets = load_config(presets_file) - -settings = {} - -# Setup -url = config.get('url', 'http://127.0.0.1:7860') - -ACCEPTED_FILE_TYPES = ["png", "jpg", "jpeg", "bmp"] - -# Global variables -img2img = None -img2img_waiting = False -img2img_time_prev = None - -hr_scales = config.get("hr_scales", [1.0, 1.25, 1.5, 2.0]) -if 1.0 not in hr_scales: - hr_scales.insert(0, 1.0) -hr_scale = hr_scales[0] -hr_scale_prev = hr_scales[1] - -hr_upscalers = config.get("hr_upscalers", ['Latent (bicubic)']) -hr_upscaler = hr_upscalers[0] - -denoising_strengths = config.get("denoising_strengths", [0.6]) -denoising_strength = denoising_strengths[0] - -samplers = config.get("samplers", ["DDIM"]) -sampler = samplers[0] - -controlnet_weights = config.get("controlnet_weights", [0.6, 1.0, 1.6]) -controlnet_weight = controlnet_weights[0] - -controlnet_guidance_ends = config.get("controlnet_guidance_ends", [1.0, 0.2, 0.3]) -controlnet_guidance_end = controlnet_guidance_ends[0] - -render_preset_fields = config.get('preset_fields', ["hr_enabled", "hr_scale", "hr_upscaler", "denoising_strength"]) -cn_preset_fields = config.get('cn_preset_fields', ["controlnet_model", "controlnet_weight", "controlnet_guidance_end"]) - -batch_sizes = config.get("batch_sizes", [1, 4, 9, 16]) -if 1 not in batch_sizes: - batch_sizes.insert(0, 1) -batch_size = batch_sizes[0] -batch_size_prev = batch_sizes[1] -batch_hr_scale_prev = hr_scale -batch_images = [] - -autosave_seed = config.get('autosave_seed', 'false') == 'true' -autosave_prompt = config.get('autosave_prompt', 'false') == 'true' -autosave_negative_prompt = config.get('autosave_negative_prompt', 'false') == 'true' -autosave_images = config.get('autosave_images', 'false') == 'true' -autosave_images_max = config.get('autosave_images_max', 5) - -main_json_data = {} -quick_mode = False -server_busy = False -rendering = False -rendering_key = False -instant_render = False -image_click = False -pause_render = False -use_invert_module = True -osd_always_on_text: str|None = None -progress = 0.0 -controlnet_models: list[str] = config.get("controlnet_models", []) - -detectors = config.get('detectors', ('lineart',)) -detector = detectors[0] - -last_detect_time = time.time() -osd_text = None -osd_text_display_start = None -clip_skip = 1 - -# Read command-line arguments -if __name__ == '__main__': - argParser = argparse.ArgumentParser() - argParser.add_argument("--img2img", help="img2img source file") - - args = argParser.parse_args() - - img2img = args.img2img - if img2img == '': - img2img = '#' # force load file dialog if launched with --img2img without value - - -def update_size_thread(**kwargs): - """ - Update interface threaded method. - - If a rendering is in progress, wait before resizing. - - :param kwargs: Accepted override parameter: ``hr_scale`` - """ - - global width, height, soft_upscale, hr_scale - - while server_busy: - # Wait for rendering to end - time.sleep(0.25) - - interface_width = config.get('interface_width', init_width * (1 if img2img else 2)) - interface_height = config.get('interface_height', init_height) - - if round(interface_width / interface_height * 100) != round(init_width * (1 if img2img else 2) / init_height * 100): - ratio = init_width / init_height - if ratio < 1: - interface_width = math.floor(interface_height * ratio) - else: - interface_height = math.floor(interface_width * ratio) - - soft_upscale = 1.0 - if interface_width != init_width * (1 if img2img else 2) or interface_height != init_height: - soft_upscale = min(config['interface_width'] / init_width, config['interface_height'] / init_height) - - if kwargs.get('hr_scale', None) is not None: - hr_scale = kwargs.get('hr_scale') - - soft_upscale = soft_upscale / hr_scale - width = math.floor(init_width * hr_scale) - height = math.floor(init_height * hr_scale) - - width = math.floor(width * soft_upscale) - height = math.floor(height * soft_upscale) - - -def update_size(**kwargs): - """ - Update the interface scale, according to image width & height, and HR scale if enabled. - :param kwargs: Accepted override parameter: ``hr_scale`` - :return: - """ - - t = threading.Thread(target=functools.partial(update_size_thread, **kwargs)) - t.start() - - -def fetch_controlnet_models(): - """ - Fetch the available ControlNet models list from the API. - :return: The ControlNet models. - """ - global controlnet_models - - controlnet_models = [] - response = requests.get(url=f'{url}/controlnet/model_list') - if response.status_code == 200: - r = response.json() - for model in r.get('model_list', []): # type: str - if 'scribble' not in model and 'lineart' not in model: - continue - - if ' [' in model: - model = model[:model.rindex(' [')] - - controlnet_models.append(model) - - def cmp_model(o1, o2): - # Sort scribble first - if 'scribble' in o1 and 'scribble' not in o2: - return -1 - elif o1 < o2: - return -1 - elif o1 > o2: - return 1 - else: - return 0 - - controlnet_models.sort(key=functools.cmp_to_key(cmp_model)) - - if controlnet_models != config['controlnet_models']: - with open(config_file, "w") as f: - config['controlnet_models'] = controlnet_models - json.dump(config, f, indent=4) - else: - print(f"Error code returned: HTTP {response.status_code}") - - -if not config['controlnet_models']: - fetch_controlnet_models() - - -# Read JSON rendering configuration files -json_file = "controlnet.json" -if img2img: - json_file = "img2img.json" - -settings = load_config(json_file) - -seed = settings.get('seed', 3456456767) -if settings.get('override_settings', None) is not None and settings['override_settings'].get('CLIP_stop_at_last_layers', None) is not None: - clip_skip = settings['override_settings']['CLIP_stop_at_last_layers'] - -if settings.get('enable_hr', 'false') == 'true': - hr_scale = hr_scales[1] - batch_hr_scale_prev = hr_scale - -prompt = settings.get('prompt', '') -negative_prompt = settings.get('negative_prompt', '') - -if settings.get("controlnet_units", None) and settings.get("controlnet_units")[0].get('pixel_perfect', None): - pixel_perfect = settings.get("controlnet_units")[0]["pixel_perfect"] == "true" -else: - pixel_perfect = False - -width = settings.get('width', 512) -height = settings.get('height', 512) -init_width = width * 1.0 -init_height = height * 1.0 -soft_upscale = 1.0 -if settings.get("controlnet_units", None) and settings.get("controlnet_units")[0].get('model', None): - controlnet_model = settings.get("controlnet_units")[0]["model"] -elif controlnet_models: - controlnet_model = controlnet_models[0] -else: - controlnet_model = None -update_size() - -if controlnet_model and settings.get("controlnet_units", None) and not settings.get("controlnet_units")[0].get('model', None): - settings['controlnet_units'][0]['model'] = controlnet_model - with open(json_file, "w") as f: - json.dump(settings, f, indent=4) - -if img2img: - if not os.path.exists(img2img): - root = tk.Tk() - root.withdraw() - img2img = filedialog.askopenfilename() - - img2img_time = os.path.getmtime(img2img) - - with Image.open(img2img, mode='r') as im: - width = im.width - height = im.height - init_width = width * 1.0 - init_height = height * 1.0 - update_size() - -# Set up the display -fullscreen = False -screen = pygame.display.set_mode((width * (1 if img2img else 2), height)) -display_caption = "Sd Paint" -pygame.display.set_caption(display_caption) - -# Setup text -font = pygame.font.SysFont(None, size=24) -font_bold = pygame.font.SysFont(None, size=24, bold=True) -text_input = "" - -# Set up the drawing surface -canvas = pygame.Surface((width*2, height)) -pygame.draw.rect(canvas, (255, 255, 255), (0, 0, width * (1 if img2img else 2), height)) - -# Set up the brush -brush_size = {1: 2, 2: 2, 'e': 10} -brush_colors = { - 1: (0, 0, 0), # Left mouse button color - 2: (255, 255, 255), # Middle mouse button color - 'e': (255, 255, 255), # Eraser color -} -brush_pos = {1: None, 2: None, 'e': None} -prev_pos = None -prev_pos2 = None -shift_pos = None -eraser_down = False -render_wait = 0.5 if not img2img else 0.0 # wait time max between 2 draw before launching the render -last_draw_time = time.time() -last_render_bytes: io.BytesIO = None - -# Define the cursor size and color -cursor_size = 1 -cursor_color = (0, 0, 0) - - -def save_preset(preset_type, index): - """ - Save the current rendering settings as preset. - :param str preset_type: The preset type. ``[render, controlnet]`` - :param int index: The preset numeric keymap. - """ - global presets - - if presets.get(preset_type, None) is None: - presets[preset_type] = {} - - index = str(index) - - if index == '0': - if preset_type == 'render': - presets[preset_type][index] = { - 'clip_skip': settings.get('override_settings', {}).get('CLIP_stop_at_last_layers', 1), - 'hr_scale': config['hr_scales'][1] if settings.get('enable_hr', 'false') == 'true' else 1.0, - 'hr_upscaler': config['hr_upscalers'][0], - 'denoising_strength': config['denoising_strengths'][0], - 'sampler': config['samplers'][0] - } - elif preset_type == 'controlnet': - presets[preset_type][index] = { - 'controlnet_weight': config['controlnet_weights'][0], - 'controlnet_guidance_end': config['controlnet_guidance_ends'][0], - 'controlnet_model': config['controlnet_models'][0] - } - else: - if presets[preset_type].get(index, None) is None: - presets[preset_type][index] = {} - - if preset_type == 'render': - presets[preset_type][index] = { - 'clip_skip': clip_skip, - 'hr_scale': hr_scale, - 'hr_upscaler': hr_upscaler, - 'denoising_strength': denoising_strength, - 'sampler': sampler - } - elif preset_type == 'controlnet': - presets[preset_type][index] = { - 'controlnet_weight': controlnet_weight, - 'controlnet_guidance_end': controlnet_guidance_end, - 'controlnet_model': controlnet_model - } - - osd(text=f"Save {preset_type} preset {index}") - - # print(f"Save {preset_type} preset {index}") - # print(presets[preset_type][index]) - - with open(presets_file, 'w') as f: - json.dump(presets, f, indent=4) - - -def load_preset(preset_type, index): - """ - Load a preset values. - :param str preset_type: The preset type. ``[render, controlnet]`` - :param int index: The preset numeric keymap. - """ - - index = str(index) - - if presets[preset_type].get(index, None) is None: - osd(text=f"No {preset_type} preset {index}") - return None - - preset = presets[preset_type][index] - - if index == '0': - text = f"Load default settings:" - - if preset_type == 'controlnet': - # prepend OSD output with render preset values for default settings display (both called successively) - for preset_field in render_preset_fields: - text += f"\n {preset_field[:1].upper()}{preset_field[1:].replace('_', ' ')} :: {presets['render'][index][preset_field]}" - else: - text = f"Load {preset_type} preset {index}:" - - # load preset - for preset_field in (render_preset_fields if preset_type == 'render' else cn_preset_fields): - if preset.get(preset_field, None) is None: - continue - - globals()[preset_field] = preset[preset_field] - text += f"\n {preset_field[:1].upper()}{preset_field[1:].replace('_', ' ')} :: {preset[preset_field]}" - - osd(text=text, text_time=4.0) - update_size() - - -def interrupt_rendering(): - """ - Interrupt current rendering. - """ - - global rendering, need_redraw - - response = requests.post(url=f'{url}/sdapi/v1/interrupt') - if response.status_code == 200: - osd(text="Interrupted rendering") - - -# Init the default preset -save_preset('render', 0) -save_preset('controlnet', 0) - - -def load_filepath_into_canvas(file_path): - """ - Load an image file on the sketch canvas. - - :param str file_path: Local image file path. - """ - global canvas - canvas = pygame.Surface((width * (1 if img2img else 2), height)) - pygame.draw.rect(canvas, (255, 255, 255), (0, 0, width * (1 if img2img else 2), height)) - img = pygame.image.load(file_path) - img = pygame.transform.smoothscale(img, (width, height)) - canvas.blit(img, (width, 0)) - - -def finger_pos(finger_x, finger_y): - x = round(min(max(finger_x, 0), 1) * width * (1 if img2img else 2)) - y = round(min(max(finger_y, 0), 1) * height) - return x, y - - -def save_file_dialog(): - """ - Display save file dialog, then write the file. - """ - global last_render_bytes - - root = tk.Tk() - root.withdraw() - file_path = filedialog.asksaveasfilename(defaultextension=".png") - - if file_path: - save_image(file_path, last_render_bytes) - time.sleep(1) # add a 1-second delay - - -def autosave_cleanup(images_type): - """ - Cleanup the autosave files. - :param str images_type: Images type to cleanup. ``[single, batch]`` - :return: - """ - - file_path = os.path.join("outputs", "autosave") - if not os.path.exists(file_path): - return - - if images_type == 'batch': - image_pattern = re.compile(r"(\d+)-(batch-\d+).png") - else: - image_pattern = re.compile(r"(\d+)-(image).png") - - files_list = list(os.listdir(file_path)) - for file in sorted(files_list, reverse=True): - m = image_pattern.match(file) - if m: - if int(m.group(1)) >= autosave_images_max: - delete_image(os.path.join(file_path, file)) - else: - rename_image(os.path.join(file_path, file), os.path.join(file_path, f"{int(m.group(1))+1:02d}-{m.group(2)}.png")) - - -def autosave_image(image_bytes): - """ - Auto save image(s) in the output dir. - :param io.BytesIO|list[io.BytesIO] image_bytes: The image(s) data. - """ - - file_path = "outputs" - if autosave_images_max > 0 and not os.path.exists(os.path.join(file_path, "autosave")): - os.makedirs(os.path.join(file_path, "autosave")) - - if isinstance(image_bytes, list): - autosave_cleanup("batch") - batch_image_pattern = re.compile(r"batch-\d+(-sketch)?.png") - - for f in os.listdir(file_path): - if not batch_image_pattern.match(f) or os.path.isdir(os.path.join(file_path, f)): - continue - - if autosave_images_max > 0: - rename_image(os.path.join(file_path, f), os.path.join(file_path, "autosave", f"01-{f}")) - - for i in range(len(image_bytes)): - file_name = f"batch-{i+1:02d}.png" - - save_image(os.path.join(file_path, file_name), image_bytes[i], save_sketch=False) - else: - autosave_cleanup("single") - - file_name = f"image.png" - if os.path.exists(os.path.join(file_path, file_name)) and autosave_images_max > 0: - rename_image(os.path.join(file_path, file_name), os.path.join(file_path, "autosave", f"01-{file_name}")) - - save_image(os.path.join(file_path, file_name), image_bytes, save_sketch=True) - - -def save_image(file_path, image_bytes, save_sketch=True): - """ - Save an image file to disk. - :param str file_path: The file path. - :param io.BytesIO image_bytes: The image data. - :param bool save_sketch: Save the sketch alongside the image. - """ - file_name, file_ext = os.path.splitext(file_path) - - file_dir = os.path.dirname(file_path) - if not os.path.exists(file_dir): - os.makedirs(file_dir) - - # save last rendered image - with open(file_path, "wb") as image_file: - image_file.write(image_bytes.getbuffer().tobytes()) - - # save sketch - if save_sketch: - sketch_img = canvas.subsurface(pygame.Rect(width, 0, width, height)).copy() - pygame.image.save(sketch_img, f"{file_name}-sketch{file_ext}") - - -def delete_image(file_path): - """ - Delete an image file, and associated sketch file if existing. - :param str file_path: The file path. - """ - - file_name, file_ext = os.path.splitext(file_path) - - if os.path.exists(file_path): - os.remove(file_path) - - if os.path.exists(f"{file_name}-sketch{file_ext}"): - os.remove(f"{file_name}-sketch{file_ext}") - - -def rename_image(src_path, dest_path): - """ - Rename an image file, and associated sketch file if existing. - :param str src_path: The source file path. - :param str dest_path: The destination file path. - """ - - src_name, src_ext = os.path.splitext(src_path) - dest_name, dest_ext = os.path.splitext(dest_path) - - if not os.path.exists(src_path): - return - - delete_image(dest_path) - - os.rename(src_path, dest_path) - - if os.path.exists(f"{src_name}-sketch{src_ext}"): - os.rename(f"{src_name}-sketch{src_ext}", f"{dest_name}-sketch{dest_ext}") - - -def load_file_dialog(): - """ - Display loading file dialog, then load the image on the sketch canvas. - """ - - root = tk.Tk() - root.withdraw() - file_path = filedialog.askopenfilename() - if not file_path: - return - - extension = os.path.splitext(file_path)[1][1:].lower() - if extension in ACCEPTED_FILE_TYPES: - load_filepath_into_canvas(file_path) - - -def update_image(image_data): - """ - Redraw the image canvas. - - :param str|bytes image_data: Base64 encoded image data, from API response. - """ - global last_render_bytes - - # Decode base64 image data - if isinstance(image_data, str): - image_data = base64.b64decode(image_data) - - img_bytes = io.BytesIO(image_data) - img_surface = pygame.image.load(img_bytes) - - if autosave_images: - autosave_image(io.BytesIO(image_data)) - last_render_bytes = io.BytesIO(image_data) # store rendered image in memory - - if soft_upscale != 1.0: - img_surface = pygame.transform.smoothscale(img_surface, (img_surface.get_width() * soft_upscale, img_surface.get_height() * soft_upscale)) - - canvas.blit(img_surface, (0, 0)) - global need_redraw - need_redraw = True - - -def update_batch_images(image_datas): - """ - Redraw the image canvas with multiple images. - - :param list[str]|list[bytes] image_datas: Images data, if ``str`` type : base64 encoded from API response. - """ - global last_render_bytes, batch_images - - # Close old batch images - if len(batch_images): - for batch_image in batch_images: - image_bytes = batch_image.get('image', None) - if isinstance(image_bytes, io.BytesIO): - image_bytes.close() - - batch_images = [] - - to_autosave = [] - nb = math.ceil(math.sqrt(len(image_datas))) - i, j, batch_index = 0, 0, 1 - for image_data in image_datas: - pos = (i * width // nb, j * height // nb) - - # Decode base64 image data - if isinstance(image_data, str): - image_data = base64.b64decode(image_data) - - img_bytes = io.BytesIO(image_data) - img_surface = pygame.image.load(img_bytes) - - if (i, j) == (0, 0): - last_render_bytes = io.BytesIO(image_data) # store first rendered image in memory - - if soft_upscale != 1.0: - img_surface = pygame.transform.smoothscale(img_surface, (img_surface.get_width() * soft_upscale // nb, img_surface.get_height() * soft_upscale // nb)) - - if autosave_images: - to_autosave.append(io.BytesIO(image_data)) - - batch_images.append({ - "seed": seed + batch_index - 1, - "image": io.BytesIO(image_data), - "coord": (pos[0], pos[1], img_surface.get_width(), img_surface.get_height()) - }) - - canvas.blit(img_surface, pos) - - # increase indices - i = (i + 1) % nb - if i == 0: - j = (j + 1) % nb - batch_index += 1 - - if to_autosave: - autosave_image(to_autosave) - - global need_redraw - need_redraw = True - - -def select_batch_image(pos): - """ - Select a batch image by clicking on it. - :param list[int]|tuple[int] pos: The event position. - """ - global need_redraw, batch_size, batch_size_prev, seed - - if not len(batch_images): - return - - for batch_image in batch_images: - if batch_image['coord'][0] <= pos[0] < batch_image['coord'][0] + batch_image['coord'][2] \ - and batch_image['coord'][1] <= pos[1] < batch_image['coord'][1] + batch_image['coord'][3]: - - need_redraw = True - osd(text_time=f"Select batch image seed {batch_image['seed']}") - - if batch_image.get('image', None): - update_image(batch_image['image'].getbuffer().tobytes()) - - seed = batch_image['seed'] - - toggle_batch_mode() - - break - - -def new_random_seed(): - """ - Generate a new random seed. - - :return: The new seed. - """ - - global seed - seed = random.randint(0, 2**32-1) - return seed - - -def img2img_submit(force=False): - """ - Read the ``img2img`` file if modified since last render, check every 1s. Call the API to render if needed. - - :param bool force: Force the rendering, even if the file is not modified. - """ - - global img2img_time_prev, img2img_time, img2img_waiting, seed, server_busy - img2img_waiting = False - - img2img_time = os.path.getmtime(img2img) - if img2img_time != img2img_time_prev or force: - img2img_time_prev = img2img_time - - with open(json_file, "r") as f: - json_data = json.load(f) - - if os.path.splitext(img2img)[1] == '.psd': - psd = PSDImage.open(img2img) - im = psd.composite() - data = io.BytesIO() - im.save(data, format="png") - data = base64.b64encode(data.getvalue()).decode('utf-8') - json_data['width'] = im.width - json_data['height'] = im.height - else: - with Image.open(img2img, mode='r') as im: - data = io.BytesIO() - im.save(data, format=im.format) - data = base64.b64encode(data.getvalue()).decode('utf-8') - json_data['width'] = im.width - json_data['height'] = im.height - - json_data['init_images'] = [data] - - json_data['seed'] = seed - json_data['prompt'] = prompt - json_data['negative_prompt'] = negative_prompt - json_data['denoising_strength'] = denoising_strength - json_data['sampler_name'] = sampler - - if json_data.get('override_settings', None) is None: - json_data['override_settings'] = {} - - json_data['override_settings']['CLIP_stop_at_last_layers'] = clip_skip - - if quick_mode: - json_data['steps'] = json_data.get('quick_steps', json_data['steps'] // 2) # use quick_steps setting, or halve steps if not set - - server_busy = True - - t = threading.Thread(target=progress_bar) - t.start() - - response = requests.post(url=f'{url}/sdapi/v1/img2img', json=json_data) - if response.status_code == 200: - r = response.json() - return_img = r['images'][0] - update_image(return_img) - r_info = json.loads(r['info']) - return_prompt = r_info['prompt'] - return_seed = r_info['seed'] - global display_caption - display_caption = f"Sd Paint | Seed: {return_seed} | Prompt: {return_prompt}" - else: - osd(text=f"Error code returned: HTTP {response.status_code}") - - server_busy = False - - if not img2img_waiting and running: - img2img_waiting = True - time.sleep(1.0) - img2img_submit() - - -def progress_request(): - """ - Call the API for rendering progression status. - - :return: The API JSON response. - """ - - response = requests.get(url=f'{url}/sdapi/v1/progress') - if response.status_code == 200: - r = response.json() - return r - else: - osd(text=f"Error code returned: HTTP {response.status_code}") - return {} - - -def progress_bar(): - """ - Update the progress bar every 0.25s - """ - global progress - - if not server_busy: - return - - progress_json = progress_request() - progress = progress_json.get('progress', None) - # if progress is not None and progress > 0.0: - # print(f"{progress*100:.0f}%") - - if server_busy: - time.sleep(0.25) - progress_bar() - - -def draw_osd_text(text, rect, color=(255, 255, 255), shadow_color=(0, 0, 0), distance=1, right_align=False): - """ - Draw OSD text with outline. - :param str text: The text to draw. - :param list[int]|tuple[int]|pygame.Rect rect: Destination rect. - :param tuple|int color: Text color. - :param tuple|int shadow_color: Outline color. - :param int distance: Outline/shadow size. - :param bool right_align: Align text to the right. - """ - - align_offset = 0 - if right_align: - align_offset = -1 - - text_surface = font.render(text, True, shadow_color) - screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset + distance, rect[1] + distance, rect[2], rect[3])) - screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset - distance, rect[1] + distance, rect[2], rect[3])) - screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset + distance, rect[1] - distance, rect[2], rect[3])) - screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset - distance, rect[1] - distance, rect[2], rect[3])) - text_surface = font.render(text, True, color) - screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset, rect[1], rect[2], rect[3])) - - -def osd(**kwargs): - """ - OSD display: progress bar and text messages. - - :param kwargs: Accepted parameters : ``progress, text, text_time, need_redraw`` - """ - - global osd_text, osd_text_display_start - - osd_size = (128, 20) - osd_margin = 10 - osd_progress_pos = (width*(0 if img2img else 1) + osd_margin, osd_margin) # top left of canvas - # osd_pos = (width*(1 if img2img else 2) // 2 - osd_size [0] // 2, osd_margin) # top center - # osd_progress_pos = (width*(1 if img2img else 2) - osd_size[0] - osd_margin, height - osd_size[1] - osd_margin) # bottom right - - osd_dot_size = osd_size[1] // 2 - # osd_dot_pos = (width*(0 if img2img else 1) + osd_margin, osd_margin, osd_dot_size, osd_dot_size) # top left - osd_dot_pos = (width*(1 if img2img else 2) - osd_dot_size * 2 - osd_margin, osd_margin, osd_dot_size, osd_dot_size) # top right - - osd_text_pos = (width*(0 if img2img else 1) + osd_margin, osd_margin) # top left of canvas - # osd_text_pos = (width*(0 if img2img else 1) + osd_margin, height - osd_size[1] - osd_margin) # bottom left of canvas - osd_text_offset = 0 - - osd_text_split_offset = 250 - - global progress, need_redraw, osd_always_on_text - - progress = kwargs.get('progress', progress) # type: float - text = kwargs.get('text', osd_text) # type: str - text_time = kwargs.get('text_time', 2.0) # type: float - need_redraw = kwargs.get('need_redraw', need_redraw) # type: bool - osd_always_on_text = kwargs.get('always_on', osd_always_on_text) - - if rendering or (server_busy and progress is not None and progress < 0.02): - rendering_dot_surface = pygame.Surface(osd_size, pygame.SRCALPHA) - - pygame.draw.circle(rendering_dot_surface, (0, 0, 0), (osd_dot_size + 2, osd_dot_size + 2), osd_dot_size - 2) - pygame.draw.circle(rendering_dot_surface, (0, 200, 160), (osd_dot_size, osd_dot_size), osd_dot_size - 2) - screen.blit(rendering_dot_surface, osd_dot_pos) - - if progress is not None and progress > 0.01: - need_redraw = True - - # progress bar - progress_surface = pygame.Surface(osd_size, pygame.SRCALPHA) - pygame.draw.rect(progress_surface, (0, 0, 0), pygame.Rect(2, 2, math.floor(osd_size[0] * progress), osd_size[1])) - pygame.draw.rect(progress_surface, (0, 200, 160), pygame.Rect(0, 0, math.floor(osd_size[0] * progress), osd_size[1] - 2)) - - screen.blit(progress_surface, pygame.Rect(osd_progress_pos[0], osd_progress_pos[1], osd_size[0], osd_size[1])) - - # progress text - draw_osd_text(f"{progress * 100:.0f}%", (osd_size[0] - osd_margin + osd_progress_pos[0], 3 + osd_progress_pos[1], osd_size[0], osd_size[1]), right_align=True) - - osd_text_offset = osd_size[1] + osd_margin - - if osd_always_on_text: - need_redraw = True - - # OSD always-on text - for line in osd_always_on_text.split('\n'): - need_redraw = True - - if '::' in line: - line, line_value = line.split('::') - line = line.rstrip(' ') - line_value = line_value.lstrip(' ') - else: - line_value = None - - draw_osd_text(line, (osd_text_pos[0], osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) - if line_value: - draw_osd_text(line_value, (osd_text_pos[0] + osd_text_split_offset, osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) - - osd_text_offset += osd_size[1] - - if text: - need_redraw = True - - # OSD text - if osd_text_display_start is None or text != osd_text: - osd_text_display_start = time.time() - osd_text = text - - for line in osd_text.split('\n'): - need_redraw = True - - if '::' in line: - line, line_value = line.split('::') - line = line.rstrip(' ') - line_value = line_value.lstrip(' ') - else: - line_value = None - - draw_osd_text(line, (osd_text_pos[0], osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) - if line_value: - draw_osd_text(line_value, (osd_text_pos[0] + osd_text_split_offset, osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) - - osd_text_offset += osd_size[1] - - if time.time() - osd_text_display_start > text_time: - osd_text = None - osd_text_display_start = None - - -def payload_submit(): - """ - Fill the payload to be sent to the API. - - Set ``main_json_data`` variable. - """ - - global main_json_data - img = canvas.subsurface(pygame.Rect(width, 0, width, height)).copy() - - if not use_invert_module: - # Convert the Pygame surface to a PIL image - pil_img = Image.frombytes('RGB', img.get_size(), pygame.image.tostring(img, 'RGB')) - - # Invert the colors of the PIL image - pil_img = ImageOps.invert(pil_img) - - # Convert the PIL image back to a Pygame surface - img = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode).convert_alpha() - - # Save the inverted image as base64-encoded data - data = io.BytesIO() - pygame.image.save(img, data) - data = base64.b64encode(data.getvalue()).decode('utf-8') - with open(json_file, "r") as f: - json_data = json.load(f) - - if quick_mode: - json_data['steps'] = json_data.get('quick_steps', json_data['steps'] // 2) # use quick_steps setting, or halve steps if not set - - json_data['controlnet_units'][0]['input_image'] = data - json_data['controlnet_units'][0]['model'] = controlnet_model - json_data['controlnet_units'][0]['weight'] = controlnet_weight - if json_data['controlnet_units'][0].get('guidance_start', None) is None: - json_data['controlnet_units'][0]['guidance_start'] = 0.0 - json_data['controlnet_units'][0]['guidance_end'] = controlnet_guidance_end - json_data['controlnet_units'][0]['pixel_perfect'] = pixel_perfect - if use_invert_module: - json_data['controlnet_units'][0]['module'] = 'invert' - if not pixel_perfect: - json_data['controlnet_units'][0]['processor_res'] = min(width, height) - - json_data['hr_second_pass_steps'] = max(8, math.floor(json_data['steps'] * denoising_strength)) # at least 8 steps - - if hr_scale > 1.0: - json_data['enable_hr'] = 'true' - else: - json_data['enable_hr'] = 'false' - - json_data['batch_size'] = batch_size - json_data['seed'] = seed - json_data['prompt'] = prompt - json_data['negative_prompt'] = negative_prompt - json_data['hr_scale'] = hr_scale - json_data['hr_upscaler'] = hr_upscaler - json_data['denoising_strength'] = denoising_strength - json_data['sampler_name'] = sampler - - if json_data.get('override_settings', None) is None: - json_data['override_settings'] = {} - - json_data['override_settings']['CLIP_stop_at_last_layers'] = clip_skip - - main_json_data = json_data - - -def controlnet_to_sdapi(json_data): - """ - Convert deprecated ``/controlnet/*2img`` JSON data to the new ``sdapi/v1/*2img`` format. - - :param dict json_data: The JSON API data. - :return: The converted payload content. - """ - - json_data = copy.deepcopy(json_data) # ensure main_json_data is left untouched - - if json_data.get('alwayson_scripts', None) is None: - json_data['alwayson_scripts'] = {} - - if not json_data['alwayson_scripts'].get('controlnet', {}): - json_data['alwayson_scripts']['controlnet'] = { - 'args': [] - } - - if json_data.get('controlnet_units', []) and not json_data.get('alwayson_scripts', {}).get('controlnet', {}).get('args', []): - if json_data.get('alwayson_scripts', None) is None: - json_data['alwayson_scripts'] = {} - - json_data['alwayson_scripts']['controlnet'] = { - 'args': json_data['controlnet_units'] - } - - del json_data['controlnet_units'] - - return json_data - - -def send_request(): - """ - Send the API request. - - Use ``main_json_data`` variable. - """ - - global server_busy - response = requests.post(url=f'{url}/sdapi/v1/{"img2img" if img2img else "txt2img"}', json=controlnet_to_sdapi(main_json_data)) - if response.status_code == 200: - r = response.json() - - ignore_images = 1 # last image returned is the sketch, ignore when updating - if hr_scale != 1.0: - ignore_images += 1 # two sketch images are returned with HR fix - - if len(r['images']) == 1 + ignore_images: - return_img = r['images'][0] - update_image(return_img) - else: - update_batch_images(r['images'][:-ignore_images]) - - r_info = json.loads(r['info']) - return_prompt = r_info['prompt'] - return_seed = r_info['seed'] - global display_caption - display_caption = f"Sd Paint | Seed: {return_seed} | Prompt: {return_prompt}" - else: - osd(text=f"Error code returned: HTTP {response.status_code}") - server_busy = False - - -def render(): - """ - Call the API to launch the rendering, if another rendering is not in progress. - """ - global server_busy, instant_render - - if time.time() - last_draw_time < render_wait and not instant_render: - time.sleep(0.25) - render() - return - - instant_render = False - - if not server_busy: - server_busy = True - - if not img2img: - payload_submit() - t = threading.Thread(target=send_request) - t.start() - t = threading.Thread(target=progress_bar) - t.start() - else: - t = threading.Thread(target=functools.partial(img2img_submit, True)) - t.start() - - -def get_angle(pos1, pos2): - """ - Get the angle between two position. - :param tuple[int]|list[int] pos1: First position. - :param tuple[int]|list[int] pos2: Second position. - :param bool deg: Get the angle as degrees, otherwise radians. - :return: radians, degrees, cos, sin - """ - - dx = pos1[0] - pos2[0] - dy = pos1[1] - pos2[1] - rads = math.atan2(-dy, dx) - rads %= 2 * math.pi - - return rads, math.degrees(rads), math.cos(rads), math.sin(rads) - - -def brush_stroke(pos): - """ - Draw the brush stroke. - :param tuple[int]|list[int] pos: The brush current position. - """ - - global prev_pos, prev_pos2 - - if prev_pos is None or (abs(event.pos[0] - prev_pos[0]) < brush_size[button] // 4 and abs(event.pos[1] - prev_pos[1]) < brush_size[button] // 4): - # Slow brush stroke, draw circles - pygame.draw.circle(canvas, brush_colors[button], event.pos, brush_size[button]) - - elif not prev_pos2 or brush_size[button] < 4: - # Draw a simple polygon for small brush sizes - pygame.draw.polygon(canvas, brush_colors[button], [prev_pos, event.pos], brush_size[button] * 2) - - else: - # Draw a complex shape with gfxdraw for bigger bush sizes to avoid gaps - angle_prev = get_angle(prev_pos, prev_pos2) - angle = get_angle(event.pos, prev_pos) - - offset_pos_prev = [(brush_size[button] * angle_prev[3]), (brush_size[button] * angle_prev[2])] - offset_pos = [(brush_size[button] * angle[3]), (brush_size[button] * angle[2])] - pygame.gfxdraw.filled_polygon(canvas, [ - (prev_pos2[0] - offset_pos_prev[0], prev_pos2[1] - offset_pos_prev[1]), - (prev_pos[0] - offset_pos[0], prev_pos[1] - offset_pos[1]), - (event.pos[0] - offset_pos[0], event.pos[1] - offset_pos[1]), - (event.pos[0] + offset_pos[0], event.pos[1] + offset_pos[1]), - (prev_pos[0] + offset_pos[0], prev_pos[1] + offset_pos[1]), - (prev_pos2[0] + offset_pos_prev[0], prev_pos2[1] + offset_pos_prev[1]) - ], brush_colors[button]) - - prev_pos2 = prev_pos - prev_pos = event.pos - - -def shift_down(): - return pygame.key.get_mods() & pygame.KMOD_SHIFT - - -def ctrl_down(): - return pygame.key.get_mods() & pygame.KMOD_CTRL - - -def alt_down(): - return pygame.key.get_mods() & pygame.KMOD_ALT - - -def controlnet_detect(): - """ - Call ControlNet active detector on the last rendered image, replace the canvas sketch by the detector result. - """ - global last_detect_time, detector - - img = canvas.subsurface(pygame.Rect(0, 0, width, height)).copy() - - # Convert the Pygame surface to a PIL image - pil_img = Image.frombytes('RGB', img.get_size(), pygame.image.tostring(img, 'RGB')) - - # Invert the colors of the PIL image - pil_img = ImageOps.invert(pil_img) - - # Convert the PIL image back to a Pygame surface - img = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode).convert_alpha() - - # Save the inverted image as base64-encoded data - data = io.BytesIO() - pygame.image.save(img, data) - data = base64.b64encode(data.getvalue()).decode('utf-8') - - json_data = { - "controlnet_module": detector, - "controlnet_input_images": [data], - "controlnet_processor_res": min(img.get_width(), img.get_height()), - "controlnet_threshold_a": 64, - "controlnet_threshold_b": 64 - } - - response = requests.post(url=f'{url}/controlnet/detect', json=json_data) - if response.status_code == 200: - r = response.json() - return_img = r['images'][0] - img_bytes = io.BytesIO(base64.b64decode(return_img)) - pil_img = Image.open(img_bytes) - pil_img = ImageOps.invert(pil_img) - pil_img = pil_img.convert('RGB') - img_surface = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode) - - canvas.blit(img_surface, (width, 0)) - else: - osd(text=f"Error code returned: HTTP {response.status_code}") - - -def display_configuration(wrap=True): - """ - Display configuration on screen. - :param bool wrap: Wrap long text. - """ - - fields = [ - '--Prompt', - 'prompt', - 'negative_prompt', - 'seed', - '--Render', - 'settings.steps', - 'settings.cfg_scale', - 'hr_scale', - 'hr_upscaler', - 'denoising_strength', - 'clip_skip', - '--ControlNet', - 'controlnet_model', - 'controlnet_weight', - 'controlnet_guidance_end', - 'pixel_perfect' - ] - - if wrap and width < 800: - wrap = 50 - elif wrap: - wrap = 80 - - text = '' - - for field in fields: - if field == 'settings.steps' and quick_mode: - field = 'settings.quick_steps' - - # Display separator - if field.startswith('--'): - text += '\n'+field[2:]+'\n' - continue - - # Field value - label = '' - value = '' - - if '.' in field: - field = field.split('.') - var = globals().get(field[0], None) - if var is None: - continue - - if isinstance(var, dict) and var.get(field[1], None) is not None: - label = field[1] - value = var.get(field[1]) - elif (isinstance(var, list) or isinstance(var, tuple)) and field[1].isnumeric() and int(field[1]) < len(var): - label = field[0] - value = var[int(field[1])] - elif getattr(var, field[1], None) is not None: - label = field[1] - value = getattr(var, field[1]) - else: - if globals().get(field, None) is not None: - label = field - value = globals().get(field) - - if label and value is not None: - value = str(value) - label = label.replace('_', ' ') - if label.endswith('prompt'): - value = value.replace(', ', ',').replace(',', ', ') # nicer prompt display - - # wrap text - if wrap and len(value) > wrap: - new_value = '' - to_wrap = 0 - for i in range(len(value)): - if i % wrap == wrap - 1: - to_wrap = i - - if to_wrap and value[i] in [' ', ')'] or (to_wrap and i - to_wrap > 5): # try to wrap after space - new_value += value[i]+'\n::' - to_wrap = 0 - continue - - new_value += value[i] - - value = new_value - - text += f" {label} :: {value}" - - text += '\n' - - osd(always_on=text.strip('\n')) - - -def toggle_batch_mode(cycle=False): - """ - Toggle batch mode on/off. Alter the setting of HR fix if needed. - :param bool cycle: Cycle the batch size value. - """ - global batch_size, batch_size_prev, batch_hr_scale_prev, hr_scale, hr_scale_prev, rendering, rendering_key - - if cycle: - rendering_key = True - batch_size = batch_sizes[(batch_sizes.index(batch_size) + 1) % len(batch_sizes)] - else: - rendering = True - if batch_size != 1: - batch_size_prev = batch_size - batch_size = 1 - else: - batch_size = batch_size_prev - - if batch_size == 1: - hr_scale = batch_hr_scale_prev - update_size() - osd(text=f"Batch rendering: off") - else: - batch_hr_scale_prev = hr_scale - hr_scale = 1.0 - update_size() - osd(text=f"Batch rendering size: {batch_size}") - - -class TextDialog(simpledialog.Dialog): - """ - Text input dialog. - """ - - def __init__(self, text, title, dialog_width=800, dialog_height=100): - self.text = text - self.dialog_width = dialog_width - self.dialog_height = dialog_height - super().__init__(None, title=title) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.quit() - gc.collect() - - def body(self, master): - self.geometry(f'{self.dialog_width}x{self.dialog_height}') - - self.e1 = tk.Text(master) - self.e1.insert("1.0", self.text) - self.e1.pack(padx=0, pady=0, fill=tk.BOTH) - - self.attributes("-topmost", True) - master.pack(fill=tk.BOTH, expand=True) - - return self.e1 - - def apply(self): - if "_"+self.e1.get("1.0", tk.INSERT)[-1:]+"_" == "_\n_": - p = self.e1.get("1.0", tk.INSERT)[:-1] + self.e1.get(tk.INSERT, tk.END) # remove new line inserted when validating the dialog with ENTER - else: - p = self.e1.get("1.0", tk.END) - self.result = p.strip("\n") - - -# Initial img2img call -if img2img: - t = threading.Thread(target=img2img_submit) - t.start() - -# Set up the main loop -running = True -need_redraw = True - -while running: - rendering = False - - # Handle events - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - - elif event.type == pygame.MOUSEBUTTONDOWN or event.type == pygame.FINGERDOWN: - # Handle brush stroke start and modifiers - if event.type == pygame.FINGERDOWN: - event.button = 1 - event.pos = finger_pos(event.x, event.y) - - if event.pos[0] < width: - image_click = True # clicked on the image part - if batch_size != 1 and len(batch_images): - select_batch_image(event.pos) - else: - need_redraw = True - last_draw_time = time.time() - - brush_key = event.button - if eraser_down: - brush_key = 'e' - - if brush_key in brush_colors: - brush_pos[brush_key] = event.pos - elif event.button == 4: # scroll up - brush_size[1] = max(1, brush_size[1] + 1) - brush_size[2] = max(1, brush_size[2] + 1) - osd(text=f"Brush size {brush_size[1]}") - - elif event.button == 5: # scroll down - brush_size[1] = max(1, brush_size[1] - 1) - brush_size[2] = max(1, brush_size[2] - 1) - osd(text=f"Brush size {brush_size[1]}") - - if shift_down() and brush_pos[brush_key] is not None: - if shift_pos is None: - shift_pos = brush_pos[brush_key] - else: - pygame.draw.polygon(canvas, brush_colors[brush_key], [shift_pos, brush_pos[brush_key]], brush_size[brush_key] * 2) - shift_pos = brush_pos[brush_key] - - elif event.type == pygame.MOUSEBUTTONUP or event.type == pygame.FINGERUP: - # Handle brush stoke end - last_draw_time = time.time() - - if event.type == pygame.FINGERUP: - event.button = 1 - event.pos = finger_pos(event.x, event.y) - - if not image_click: - need_redraw = True - rendering = True - - if event.button in brush_colors or eraser_down: - brush_key = event.button - if eraser_down: - brush_key = 'e' - - if brush_size[brush_key] >= 4 and getattr(event, 'pos', None) is not None: - pygame.draw.circle(canvas, brush_colors[brush_key], event.pos, brush_size[brush_key]) - - brush_pos[brush_key] = None - prev_pos = None - brush_color = brush_colors[brush_key] - - image_click = False # reset image click detection - - elif event.type == pygame.MOUSEMOTION or event.type == pygame.FINGERMOTION: - # Handle drawing brush strokes - if event.type == pygame.FINGERMOTION: - event.pos = finger_pos(event.x, event.y) - - if not image_click: - need_redraw = True - for button, pos in brush_pos.items(): - if pos is not None and button in brush_colors: - last_draw_time = time.time() - brush_stroke(pos) # do the brush stroke - - elif event.type == pygame.KEYDOWN: - # DBG key & modifiers - # print(f"key down {event.key}, modifiers:") - # print(f" shift: {pygame.key.get_mods() & pygame.KMOD_SHIFT}") - # print(f" ctrl: {pygame.key.get_mods() & pygame.KMOD_CTRL}") - # print(f" alt: {pygame.key.get_mods() & pygame.KMOD_ALT}") - - # Handle keyboard shortcuts - need_redraw = True - last_draw_time = time.time() - rendering = False - - event.button = 1 - if event.key == pygame.K_UP: - rendering = True - instant_render = True - seed = seed + batch_size - update_config(json_file, write=autosave_seed, values={'seed': seed}) - osd(text=f"Seed: {seed}") - - elif event.key == pygame.K_DOWN: - rendering = True - instant_render = True - seed = seed - batch_size - update_config(json_file, write=autosave_seed, values={'seed': seed}) - osd(text=f"Seed: {seed}") - - elif event.key == pygame.K_n: - if ctrl_down(): - with TextDialog(seed, title="Seed", dialog_width=200, dialog_height=30) as dialog: - if dialog.result and dialog.result.isnumeric(): - osd(text=f"Seed: {dialog.result}") - seed = int(dialog.result) - update_config(json_file, write=autosave_seed, values={'seed': seed}) - rendering = True - instant_render = True - else: - rendering = True - instant_render = True - seed = new_random_seed() - update_config(json_file, write=autosave_seed, values={'seed': seed}) - osd(text=f"Seed: {seed}") - - elif event.key == pygame.K_c: - if shift_down(): - rendering_key = True - clip_skip -= 1 - clip_skip = (clip_skip + 1) % 2 - clip_skip += 1 - osd(text=f"CLIP skip: {clip_skip}") - else: - display_configuration() - - elif event.key == pygame.K_m and controlnet_model: - if shift_down(): - rendering_key = True - controlnet_model = controlnet_models[(controlnet_models.index(controlnet_model) + 1) % len(controlnet_models)] - osd(text=f"ControlNet model: {controlnet_model}") - - elif event.key == pygame.K_i: - if ctrl_down(): - interrupt_rendering() - - elif event.key == pygame.K_h: - if shift_down(): - rendering_key = True - hr_scale = hr_scales[(hr_scales.index(hr_scale)+1) % len(hr_scales)] - else: - rendering = True - if hr_scale != 1.0: - hr_scale_prev = hr_scale - hr_scale = 1.0 - else: - hr_scale = hr_scale_prev - - if hr_scale == 1.0: - osd(text="HR scale: off") - else: - osd(text=f"HR scale: {hr_scale}") - - update_size(hr_scale=hr_scale) - - elif event.key in (pygame.K_KP_ENTER, pygame.K_RETURN): - rendering = True - instant_render = True - osd(text=f"Rendering") - - elif event.key == pygame.K_q: - rendering = True - instant_render = True - quick_mode = not quick_mode - if quick_mode: - osd(text=f"Quick render: on") - hr_scale_prev = hr_scale - hr_scale = 1.0 - else: - osd(text=f"Quick render: off") - hr_scale = hr_scale_prev - - update_size(hr_scale=hr_scale) - - elif event.key == pygame.K_a: - autosave_images = not autosave_images - osd(text=f"Autosave images: {'on' if autosave_images else 'off'}") - - elif event.key == pygame.K_o: - if ctrl_down(): - rendering = True - instant_render = True - load_file_dialog() - - elif event.key in (pygame.K_KP0, pygame.K_KP1, pygame.K_KP2, pygame.K_KP3, pygame.K_KP4, - pygame.K_KP5, pygame.K_KP6, pygame.K_KP7, pygame.K_KP8, pygame.K_KP9): - keymap = { - pygame.K_KP0: 0, - pygame.K_KP1: 1, - pygame.K_KP2: 2, - pygame.K_KP3: 3, - pygame.K_KP4: 4, - pygame.K_KP5: 5, - pygame.K_KP6: 6, - pygame.K_KP7: 7, - pygame.K_KP8: 8, - pygame.K_KP9: 9 - } - - if ctrl_down(): - if event.key != pygame.K_KP0: - save_preset('controlnet' if alt_down() else 'render', keymap.get(event.key)) - else: - rendering = True - instant_render = True - - if event.key == pygame.K_KP0: - # Reset both render & controlnet settings if keypad 0 - load_preset('render', keymap.get(event.key)) - load_preset('controlnet', keymap.get(event.key)) - else: - load_preset('controlnet' if alt_down() else 'render', keymap.get(event.key)) - - elif event.key == pygame.K_p: - if ctrl_down(): - pause_render = not pause_render - - if pause_render: - osd(text=f"On-demand rendering (ENTER to render)") - - else: - rendering = True - instant_render = True - osd(text=f"Dynamic rendering") - elif alt_down(): - with TextDialog(negative_prompt, title="Negative prompt") as dialog: - if dialog.result: - osd(text=f"New negative prompt: {dialog.result}") - negative_prompt = dialog.result - update_config(json_file, write=autosave_negative_prompt, values={'negative_prompt': negative_prompt}) - rendering = True - instant_render = True - else: - with TextDialog(prompt, title="Prompt") as dialog: - if dialog.result: - osd(text=f"New prompt: {dialog.result}") - prompt = dialog.result - update_config(json_file, write=autosave_prompt, values={'prompt': prompt}) - rendering = True - instant_render = True - - elif event.key == pygame.K_BACKSPACE: - pygame.draw.rect(canvas, (255, 255, 255), (width, 0, width, height)) - - elif event.key == pygame.K_s: - if ctrl_down(): - save_file_dialog() - elif shift_down(): - rendering_key = True - sampler = samplers[(samplers.index(sampler) + 1) % len(samplers)] - osd(text=f"Sampler: {sampler}") - - elif event.key == pygame.K_e: - eraser_down = True - - elif event.key == pygame.K_t: - if shift_down(): - if render_wait == 2.0: - render_wait = 0.0 - osd(text="Render wait: off") - else: - render_wait += 0.5 - osd(text=f"Render wait: {render_wait}s") - - elif event.key == pygame.K_u: - if shift_down(): - rendering_key = True - hr_upscaler = hr_upscalers[(hr_upscalers.index(hr_upscaler) + 1) % len(hr_upscalers)] - osd(text=f"HR upscaler: {hr_upscaler}") - - elif event.key == pygame.K_b: - toggle_batch_mode(cycle=shift_down()) - - elif event.key == pygame.K_w: - if shift_down(): - rendering_key = True - controlnet_weight = controlnet_weights[(controlnet_weights.index(controlnet_weight) + 1) % len(controlnet_weights)] - osd(text=f"ControlNet weight: {controlnet_weight}") - - elif event.key == pygame.K_g: - if shift_down() and ctrl_down(): - rendering_key = True - pixel_perfect = not pixel_perfect - osd(text=f"ControlNet pixel perfect mode: {'on' if pixel_perfect else 'off'}") - elif shift_down(): - rendering_key = True - controlnet_guidance_end = controlnet_guidance_ends[(controlnet_guidance_ends.index(controlnet_guidance_end) + 1) % len(controlnet_guidance_ends)] - osd(text=f"ControlNet guidance end: {controlnet_guidance_end}") - - elif event.key == pygame.K_d: - if shift_down(): - rendering_key = True - denoising_strength = denoising_strengths[(denoising_strengths.index(denoising_strength) + 1) % len(denoising_strengths)] - if img2img: - osd(text=f"Denoising: {denoising_strength}") - else: - osd(text=f"HR denoising: {denoising_strength}") - elif ctrl_down(): - osd(text=f"Detect {detector}") - - t = threading.Thread(target=controlnet_detect()) - t.start() - - # select next detector - detector = detectors[(detectors.index(detector)+1) % len(detectors)] - - elif event.key == pygame.K_f: - fullscreen = not fullscreen - if fullscreen: - screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN) - else: - screen = pygame.display.set_mode((width*2, height)) - - elif event.key in (pygame.K_ESCAPE, pygame.K_x): - running = False - pygame.quit() - exit(0) - - elif event.type == pygame.KEYUP: - # Handle special keys release - if event.key == pygame.K_e: - eraser_down = False - brush_pos['e'] = None - - elif event.key in (pygame.K_LSHIFT, pygame.K_RSHIFT): - if rendering_key: - rendering = True - rendering_key = False - shift_pos = None - - elif event.key in (pygame.K_c,): - # Remove OSD always-on text - need_redraw = True - osd(always_on=None) - - # Call image render - if (rendering and not pause_render) or instant_render: - t = threading.Thread(target=render) - t.start() - - # Draw the canvas and brushes on the screen - screen.blit(canvas, (0, 0)) - - # Create a new surface with a circle - cursor_size = brush_size[1]*2 - cursor_surface = pygame.Surface((cursor_size, cursor_size), pygame.SRCALPHA) - pygame.draw.circle(cursor_surface, cursor_color, (cursor_size // 2, cursor_size // 2), cursor_size // 2) - - # Blit the cursor surface onto the screen surface at the position of the mouse - mouse_pos = pygame.mouse.get_pos() - screen.blit(cursor_surface, (mouse_pos[0] - cursor_size // 2, mouse_pos[1] - cursor_size // 2)) - - # Handle OSD - osd() - - # Update the display - if need_redraw: - pygame.display.flip() - pygame.display.set_caption(display_caption) - need_redraw = False - - # Set max FPS - clock.tick(120) - -# Clean up Pygame -pygame.quit() diff --git a/SdPaint.py b/SdPaint.py new file mode 100644 index 0000000..ccfd506 --- /dev/null +++ b/SdPaint.py @@ -0,0 +1,17 @@ +import argparse + +from scripts.views.PygameView import PygameView + +# Read command-line arguments +if __name__ == '__main__': + argParser = argparse.ArgumentParser() + argParser.add_argument("--img2img", help="img2img source file") + + args = argParser.parse_args() + + img2img = args.img2img + if img2img == '': + img2img = '#' # force load file dialog if launched with --img2img without value + + view = PygameView(img2img) + view.main() diff --git a/Start.bat b/Start.bat index ede568f..acb0b0e 100644 --- a/Start.bat +++ b/Start.bat @@ -13,7 +13,7 @@ REM Install required packages pip install -r requirements.txt REM Run the script -python Scripts/SdPaint.py +python SdPaint.py REM Deactivate the virtual environment deactivate diff --git a/Start_img2img.bat b/Start_img2img.bat index bea343b..7a19a18 100644 --- a/Start_img2img.bat +++ b/Start_img2img.bat @@ -17,7 +17,7 @@ pip install -r requirements.txt REM Run the script -python Scripts/SdPaint.py --img2img "!var!" +python SdPaint.py --img2img "!var!" REM Deactivate the virtual environment deactivate diff --git a/controlnet-high.json-dist b/controlnet-high.json-dist index 99093d4..2f25dfc 100644 --- a/controlnet-high.json-dist +++ b/controlnet-high.json-dist @@ -19,7 +19,6 @@ "guidance_start": 0.0, "guidance_end": 1.0, "module": "none", - "guessmode": "false", "pixel_perfect": "false" } ] diff --git a/controlnet.json-dist b/controlnet.json-dist index 38647c8..03b65a5 100644 --- a/controlnet.json-dist +++ b/controlnet.json-dist @@ -19,7 +19,6 @@ "guidance_start": 0.0, "guidance_end": 1.0, "module": "none", - "guessmode": "false", "pixel_perfect": "false" } ] diff --git a/scripts/common/cn_requests.py b/scripts/common/cn_requests.py new file mode 100644 index 0000000..1485f77 --- /dev/null +++ b/scripts/common/cn_requests.py @@ -0,0 +1,139 @@ +import functools +import requests +import json +from .utils import get_img2img_json, controlnet_to_sdapi + + +def fetch_controlnet_models(state): + """ + Fetch the available ControlNet models list from the API. + :param State state: Application state. + :return: The ControlNet models. + """ + + controlnet_models = [] + response = requests.get(url=f'{state.server["url"]}/controlnet/model_list') + if response.status_code == 200: + r = response.json() + for model in r.get('model_list', []): # type: str + if 'scribble' not in model and 'lineart' not in model: + continue + + if ' [' in model: + model = model[:model.rindex(' [')] + + controlnet_models.append(model) + + def cmp_model(o1, o2): + # Sort scribble first + if 'scribble' in o1 and 'scribble' not in o2: + return -1 + elif o1 < o2: + return -1 + elif o1 > o2: + return 1 + else: + return 0 + + controlnet_models.sort(key=functools.cmp_to_key(cmp_model)) + + if controlnet_models != state.configuration["config"]['controlnet_models']: + with open(state['configuration']["config_file"], "w") as f: + state.configuration["config"]['controlnet_models'] = controlnet_models + json.dump(state.configuration["config"], f, indent=4) + else: + print(f"Error code returned: HTTP {response.status_code}") + + state.control_net["controlnet_models"] = controlnet_models + + +def progress_request(state): + """ + Call the API for rendering progression status. + :param State state: Application state. + :return: The API JSON response. + """ + + response = requests.get(url=f'{state.server["url"]}/sdapi/v1/progress') + if response.status_code == 200: + return response.json() + else: + return {"status_code": response.status_code} + + +def fetch_detect_image(state, detector, image, width, height, thresholds=None): + """ + Call detect image feature from the API. + :param State state: Application state. + :param str detector: The detector to use. + :param str image: Base64 encoder image. + :param int width: Image width. + :param int height: Image height. + :param tuple[int]|None thresholds: Detector thresholds. ``(default: 64, 64)`` + :return: Requested status, image(s), and info. + """ + + # Default thresholds + if thresholds is None: + if detector == 'scribble_xdog': + thresholds = (32, 32) + elif detector == 'mlsd': + thresholds = (0.1, 0.1) + else: + thresholds = (64, 64) + + json_data = { + "controlnet_module": detector, + "controlnet_input_images": [image], + "controlnet_processor_res": min(width, height), + "controlnet_threshold_a": thresholds[0], + "controlnet_threshold_b": thresholds[1] + } + + response = requests.post(url=f'{state.server["url"]}/controlnet/detect', json=json_data) + if response.status_code == 200: + r = response.json() + return {"status_code": response.status_code, "image": r['images'][0], "info": r["info"]} + else: + return {"status_code": response.status_code} + + +def fetch_img2img(state): + """ + Call img2img from the API. + :param State state: Application state. + :return: Requested status, image(s), and info. + """ + json_data = get_img2img_json(state) + response = requests.post(url=f'{state.server["url"]}/sdapi/v1/img2img', json=json_data) + if response.status_code == 200: + r = response.json() + return {"status_code": response.status_code, "image": r['images'][0], "info": r["info"]} + else: + return {"status_code": response.status_code} + + +def post_request(state): + """ + POST a request to the API. + :param State state: Application state. + :return: Requested status, image(s), and info. + """ + response = requests.post(url=f'{state.server["url"]}/sdapi/v1/{"img2img" if state.img2img else "txt2img"}', json=controlnet_to_sdapi(state["main_json_data"])) + if response.status_code == 200: + r = response.json() + + ignore_images = 1 # last image returned is the sketch, ignore when updating + if state.render["hr_scale"] != 1.0: + ignore_images += 1 # two sketch images are returned with HR fix + + if len(r['images']) == 1 + ignore_images: + return {"status_code": response.status_code, "image": r['images'][0], "info": r["info"]} + else: + return {"status_code": response.status_code, "batch_images": r['images'][:-ignore_images], "info": r["info"]} + else: + return {"status_code": response.status_code} + + +# Type hinting imports: +from .state import State diff --git a/scripts/common/output_files_utils.py b/scripts/common/output_files_utils.py new file mode 100644 index 0000000..f31036e --- /dev/null +++ b/scripts/common/output_files_utils.py @@ -0,0 +1,119 @@ +import os +import re + + +def delete_image(file_path): + """ + Delete an image file, and associated sketch file if existing. + :param str file_path: The file path. + """ + + file_name, file_ext = os.path.splitext(file_path) + + if os.path.exists(file_path): + os.remove(file_path) + + if os.path.exists(f"{file_name}-sketch{file_ext}"): + os.remove(f"{file_name}-sketch{file_ext}") + + +def rename_image(src_path, dest_path): + """ + Rename an image file, and associated sketch file if existing. + :param str src_path: The source file path. + :param str dest_path: The destination file path. + """ + + src_name, src_ext = os.path.splitext(src_path) + dest_name, dest_ext = os.path.splitext(dest_path) + + if not os.path.exists(src_path): + return + + delete_image(dest_path) + + os.rename(src_path, dest_path) + + if os.path.exists(f"{src_name}-sketch{src_ext}"): + os.rename(f"{src_name}-sketch{src_ext}", f"{dest_name}-sketch{dest_ext}") + + +def autosave_cleanup(state, images_type): + """ + Cleanup the autosave files. + :param str images_type: Images type to cleanup. ``[single, batch]`` + :return: + """ + + file_path = os.path.join("outputs", "autosave") + if not os.path.exists(file_path): + return + + if images_type == 'batch': + image_pattern = re.compile(r"(\d+)-(batch-\d+).png") + else: + image_pattern = re.compile(r"(\d+)-(image).png") + + files_list = list(os.listdir(file_path)) + for file in sorted(files_list, reverse=True): + m = image_pattern.match(file) + if m: + if int(m.group(1)) >= state.autosave["images_max"]: + delete_image(os.path.join(file_path, file)) + else: + rename_image(os.path.join(file_path, file), os.path.join(file_path, f"{int(m.group(1))+1:02d}-{m.group(2)}.png")) + + +def autosave_image(state, image_bytes): + """ + Auto save image(s) in the output dir. + :param io.BytesIO|list[io.BytesIO] image_bytes: The image(s) data. + """ + + file_path = "outputs" + if state.autosave["images_max"] > 0 and not os.path.exists(os.path.join(file_path, "autosave")): + os.makedirs(os.path.join(file_path, "autosave")) + + if isinstance(image_bytes, list): + autosave_cleanup(state, "batch") + batch_image_pattern = re.compile(r"batch-\d+(-sketch)?.png") + + for f in os.listdir(file_path): + if not batch_image_pattern.match(f) or os.path.isdir(os.path.join(file_path, f)): + continue + + if state.autosave["images_max"] > 0: + rename_image(os.path.join(file_path, f), os.path.join(file_path, "autosave", f"01-{f}")) + + file_names = [] + for i in range(len(image_bytes)): + file_name = f"batch-{i+1:02d}.png" + file_names.append(os.path.join(file_path, file_name)) + save_image(os.path.join(file_path, file_name), image_bytes[i]) + return file_names + else: + autosave_cleanup(state, "single") + + file_name = f"image.png" + if os.path.exists(os.path.join(file_path, file_name)) and state.autosave["images_max"] > 0: + rename_image(os.path.join(file_path, file_name), os.path.join(file_path, "autosave", f"01-{file_name}")) + + save_image(os.path.join(file_path, file_name), image_bytes) + return os.path.join(file_path, file_name) + + +def save_image(file_path, image_bytes): + """ + Save an image file to disk. + :param str file_path: The file path. + :param io.BytesIO image_bytes: The image data. + :param bool save_sketch: Save the sketch alongside the image. + """ + + file_dir = os.path.dirname(file_path) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # save last rendered image + with open(file_path, "wb") as image_file: + image_file.write(image_bytes.getbuffer().tobytes()) \ No newline at end of file diff --git a/scripts/common/state.py b/scripts/common/state.py new file mode 100644 index 0000000..ba7029d --- /dev/null +++ b/scripts/common/state.py @@ -0,0 +1,182 @@ +import json +from .utils import load_config, update_size + + +class State: + """ + Store the current state of the application. + """ + + configuration = { + "config_file": "config.json", + "config": {}, + } + presets = { + "presets_file": "presets.json", + "list": load_config("presets.json"), + "fields": ["hr_enabled", "hr_scale", "hr_upscaler", "denoising_strength"], + } + server = { + "url": 'http://127.0.0.1:7860', + "busy": False, + } + render = { + "hr_scales": [], + "hr_scale": 1.0, + "hr_scale_prev": 1.25, + "hr_upscalers": ['Latent (bicubic)'], + "hr_upscaler": 'Latent (bicubic)', + 'denoising_strengths': [0.6], + 'denoising_strength': 0.6, + "batch_sizes": [1, 4, 9, 16], + "batch_size": 1, + "batch_size_prev": 4, + "batch_hr_scale_prev": 1.0, + "clip_skip": 1, + "width": 512, + "height": 512, + "soft_upscale": 1.0, + "pixel_perfect": False, + "use_invert_module": True, + "quick_mode": False, + } + control_net = { + "controlnet_models": [], + "controlnet_model": None, + "controlnet_weights": [0.6, 1.0, 1.6], + "controlnet_guidance_ends": [1.0, 0.2, 0.3], + "controlnet_guidance_end": 1.0, + "preset_fields": ["controlnet_model", "controlnet_weight", "controlnet_guidance_end"], + } + samplers = { + "list": ["DDIM"], + "sampler": "DDIM", + } + detectors = { + "list": ('lineart',), + "detector": "lineart", + } + autosave = { + "seed": False, + "prompt": False, + "negative_prompt": False, + "images": False, + "images_max": 5, + } + json_file = "controlnet.json" + main_json_data = {} + settings = {} + img2img = "" + gen_settings = { + "seed": 3456456767, + "prompt": "", + "negative_prompt": "", + } + + def __init__(self, img2img="#"): + self.img2img = img2img + self.update_config() + self.update_settings() + + def update_config(self): + """ + Update global configuration. + """ + self.configuration["config"] = load_config("config.json") + + hr_scales = self.configuration["config"].get("hr_scales", [1.0, 1.25, 1.5, 2.0]) + if 1.0 not in hr_scales: + hr_scales.insert(0, 1.0) + hr_scale = hr_scales[0] + + self.render["hr_scale_prev"] = hr_scales[1] + self.render["hr_upscalers"] = self.configuration["config"].get("hr_upscalers", ['Latent (bicubic)']) + self.render["hr_upscaler"] = self.render["hr_upscalers"][0] + self.render["denoising_strengths"] = self.configuration["config"].get("denoising_strengths", [0.6]) + self.render["denoising_strength"] = self.render["denoising_strengths"][0] + + self.samplers["list"] = self.configuration["config"].get("samplers", ["DDIM"]) + self.samplers["sampler"] = self.samplers["list"][0] + + self.detectors["list"] = self.configuration["config"].get('detectors', ('lineart',)) + self.detectors["detector"] = self.detectors["list"][0] + + self.control_net["controlnet_models"]: list[str] = self.configuration["config"].get("controlnet_models", []) + self.control_net["controlnet_weights"] = self.configuration["config"].get("controlnet_weights", [0.6, 1.0, 1.6]) + self.control_net["controlnet_weight"] = self.control_net["controlnet_weights"][0] + self.control_net["controlnet_guidance_ends"] = self.configuration["config"].get("controlnet_guidance_ends", [1.0, 0.2, 0.3]) + self.control_net["controlnet_guidance_end"] = self.control_net["controlnet_guidance_ends"][0] + + self.control_net["preset_fields"] = self.configuration["config"].get('cn_preset_fields', ["controlnet_model", "controlnet_weight", "controlnet_guidance_end"]) + self.presets["fields"] = self.configuration["config"].get('preset_fields', ["hr_enabled", "hr_scale", "hr_upscaler", "denoising_strength"]) + + batch_sizes = self.configuration["config"].get("batch_sizes", [1, 4, 9, 16]) + if 1 not in batch_sizes: + batch_sizes.insert(0, 1) + self.render["batch_size"] = batch_sizes[0] + self.render["batch_size_prev"] = batch_sizes[1] + self.render["batch_hr_scale_prev"] = hr_scale + self.render["batch_images"] = [] + self.render["hr_scales"] = hr_scales + self.render["hr_scale"] = hr_scale + self.render["batch_sizes"] = batch_sizes + + self.autosave["seed"] = self.configuration["config"].get('autosave_seed', 'false') == 'true' + self.autosave["prompt"] = self.configuration["config"].get('autosave_prompt', 'false') == 'true' + self.autosave["negative_prompt"] = self.configuration["config"].get('autosave_negative_prompt', 'false') == 'true' + self.autosave["images"] = self.configuration["config"].get('autosave_images', 'false') == 'true' + self.autosave["images_max"] = self.configuration["config"].get('autosave_images_max', 5) + + self.server["url"] = self.configuration["config"].get('url', 'http://127.0.0.1:7860') + + def update_settings(self): + """ + Update rendering settings. + """ + if self.img2img: + self.json_file = "img2img.json" + else: + self.json_file = "controlnet.json" + + self.settings = load_config(self.json_file) + settings = self.settings + + self.gen_settings["seed"] = settings.get('seed', 3456456767) + if settings.get('override_settings', None) is not None and settings['override_settings'].get('CLIP_stop_at_last_layers', None) is not None: + self.render["clip_skip"] = settings['override_settings']['CLIP_stop_at_last_layers'] + + if settings.get('enable_hr', 'false') == 'true': + self.render["hr_scale"] = self.render["hr_scales"][1] + self.render["batch_hr_scale_prev"] = self.render["hr_scale"] + + self.gen_settings["prompt"] = settings.get('prompt', '') + self.gen_settings["negative_prompt"] = settings.get('negative_prompt', '') + + if settings.get("controlnet_units", None) and settings.get("controlnet_units")[0].get('pixel_perfect', None): + self.render["pixel_perfect"] = settings.get("controlnet_units")[0]["pixel_perfect"] == "true" + else: + self.render["pixel_perfect"] = False + + self.render["width"] = settings.get('width', 512) + self.render["height"] = settings.get('height', 512) + self.render["init_width"] = self.render["width"] * 1.0 + self.render["init_height"] = self.render["height"] * 1.0 + self.render["soft_upscale"] = 1.0 + if settings.get("controlnet_units", None) and settings.get("controlnet_units")[0].get('model', None): + self.control_net["controlnet_model"] = settings.get("controlnet_units")[0]["model"] + elif self.control_net["controlnet_models"]: + self.control_net["controlnet_model"] = self.control_net["controlnet_models"][0] + else: + self.control_net["controlnet_model"] = None + update_size(self) + + if self.control_net["controlnet_models"] and settings.get("controlnet_units", None) and not settings.get("controlnet_units")[0].get('model', None): + settings['controlnet_units'][0]['model'] = self.control_net["controlnet_models"] + with open(self.json_file, "w") as f: + json.dump(settings, f, indent=4) + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __getitem__(self, key): + return getattr(self, key) diff --git a/scripts/common/utils.py b/scripts/common/utils.py new file mode 100644 index 0000000..29e3375 --- /dev/null +++ b/scripts/common/utils.py @@ -0,0 +1,316 @@ +import copy +import functools +import os +import random +import shutil +import threading +import base64 +import io +import json +import time +import math +from PIL import Image +from psd_tools import PSDImage + + +def load_config(config_file): + """ + Load a configuration file, update the local configuration file with missing settings + from distribution file if needed. + :param str config_file: The configuration file name. + :return: The configuration file content. + """ + + if not os.path.exists(config_file): + shutil.copy(f"{config_file}-dist", config_file) + + with open(config_file, "r") as f: + config_content: dict = json.load(f) + + with open(f"{config_file}-dist", "r") as f: + config_dist_content: dict = json.load(f) + + # Update local config with new settings + if isinstance(config_content, dict): + if config_content.keys() != config_dist_content.keys(): + config_dist_content.update(config_content) + config_content = config_dist_content + + print(f"Updated {config_file} with new settings.") + + with open(config_file, "w") as f: + json.dump(config_content, f, indent=4) + + return config_content + + +def update_config(config_file, write=False, values=None): + """ + Update configuration, overwriting given fields. Optionally save the configuration to local file. + :param str config_file: The configuration file name. + :param bool write: Write the configuration file on disk. + :param dict values: The arguments to overwrite. + :return: + """ + + if not os.path.exists(config_file): + shutil.copy(f"{config_file}-dist", config_file) + + with open(config_file, "r") as f: + config_content: dict = json.load(f) + + if values: + config_content.update(values) + + if write: + with open(config_file, "w") as f: + json.dump(config_content, f, indent=4) + + return config_content + + +def controlnet_to_sdapi(json_data): + """ + Convert deprecated ``/controlnet/*2img`` JSON data to the new ``sdapi/v1/*2img`` format. + :param dict json_data: The JSON API data. + :return: The converted payload content. + """ + + json_data = copy.deepcopy(json_data) # ensure main_json_data is left untouched + + if json_data.get('alwayson_scripts', None) is None: + json_data['alwayson_scripts'] = {} + + if not json_data['alwayson_scripts'].get('controlnet', {}): + json_data['alwayson_scripts']['controlnet'] = { + 'args': [] + } + + if json_data.get('controlnet_units', []) and not json_data.get('alwayson_scripts', {}).get('controlnet', {}).get('args', []): + if json_data.get('alwayson_scripts', None) is None: + json_data['alwayson_scripts'] = {} + + json_data['alwayson_scripts']['controlnet'] = { + 'args': json_data['controlnet_units'] + } + + del json_data['controlnet_units'] + + return json_data + + +def save_preset(state, preset_type, index): + """ + Save the current rendering settings as preset. + :param State state: Application state. + :param str preset_type: The preset type. ``[render, controlnet]`` + :param int index: The preset numeric keymap. + """ + presets = state.presets["list"] + + if presets.get(preset_type, None) is None: + presets[preset_type] = {} + + index = str(index) + + if index == '0': + if preset_type == 'render': + presets[preset_type][index] = { + 'clip_skip': state.settings.get('override_settings', {}).get('CLIP_stop_at_last_layers', 1), + 'hr_scale': state.configuration["config"]['hr_scales'][1] if state.settings.get('enable_hr', 'false') == 'true' else 1.0, + 'hr_upscaler': state.configuration["config"]['hr_upscalers'][0], + 'denoising_strength': state.configuration["config"]['denoising_strengths'][0], + 'sampler': state.configuration["config"]['samplers'][0] + } + elif preset_type == 'controlnet': + presets[preset_type][index] = { + 'controlnet_weight': state.configuration["config"]['controlnet_weights'][0], + 'controlnet_guidance_end': state.configuration["config"]['controlnet_guidance_ends'][0], + 'controlnet_model': state.configuration["config"]['controlnet_models'][0] + } + else: + if presets[preset_type].get(index, None) is None: + presets[preset_type][index] = {} + + if preset_type == 'render': + presets[preset_type][index] = { + 'clip_skip': state.render["clip_skip"], + 'hr_scale': state.render["hr_scale"], + 'hr_upscaler': state.render["hr_upscaler"], + 'denoising_strength': state.render["denoising_strengths"], + 'sampler': state.samplers["sampler"] + } + elif preset_type == 'controlnet': + presets[preset_type][index] = { + 'controlnet_weight': state.control_net["controlnet_weight"], + 'controlnet_guidance_end': state.control_net["controlnet_guidance_end"], + 'controlnet_model': state.control_net["controlnet_models"] + } + + # print(f"Save {preset_type} preset {index}") + # print(presets[preset_type][index]) + + with open(state.presets["presets_file"], 'w') as f: + json.dump(presets, f, indent=4) + return {"preset_type": preset_type, "index": index} + + +def update_size_thread(state, **kwargs): + """ + Update interface threaded method. + + If a rendering is in progress, wait before resizing. + :param State state: Application state. + :param kwargs: Accepted override parameter: ``hr_scale`` + """ + + while state.server["busy"]: + # Wait for rendering to end + time.sleep(0.25) + + interface_width = state.configuration["config"].get('interface_width', state.render["init_width"] * (1 if state.img2img else 2)) + interface_height = state.configuration["config"].get('interface_height', state.render["init_height"]) + + if round(interface_width / interface_height * 100) != round(state.render["init_width"] * (1 if state.img2img else 2) / state.render["init_height"] * 100): + ratio = state.render["init_width"] / state.render["init_height"] + if ratio < 1: + interface_width = math.floor(interface_height * ratio) + else: + interface_height = math.floor(interface_width * ratio) + + state.render["soft_upscale"] = 1.0 + if interface_width != state.render["init_width"] * (1 if state.img2img else 2) or interface_height != state.render["init_height"]: + state.render["soft_upscale"] = min(state.configuration["config"]['interface_width'] / state.render["init_width"], state.configuration["config"]['interface_height'] / state.render["init_height"]) + + if kwargs.get('hr_scale', None) is not None: + hr_scale = kwargs.get('hr_scale') + else: + hr_scale = state.render["hr_scale"] + + state.render["soft_upscale"] = state.render["soft_upscale"] / hr_scale + state.render["width"] = math.floor(state.render["init_width"] * hr_scale) + state.render["height"] = math.floor(state.render["init_height"] * hr_scale) + + state.render["render_size"] = (state.render["width"], state.render["height"]) + + state.render["width"] = math.floor(state.render["width"] * state.render["soft_upscale"]) + state.render["height"] = math.floor(state.render["height"] * state.render["soft_upscale"]) + + +def update_size(state, **kwargs): + """ + Update the interface scale, according to image width & height, and HR scale if enabled. + :param State state: Application state. + :param kwargs: Accepted override parameter: ``hr_scale`` + """ + + t = threading.Thread(target=functools.partial(update_size_thread, state, **kwargs)) + t.start() + + +def new_random_seed(state): + """ + Generate a new random seed. + :param State state: Application state. + :return: The new seed. + """ + + state.gen_settings["seed"] = random.randint(0, 2**32-1) + return state.gen_settings["seed"] + + +def payload_submit(state, image_string): + """ + Fill the payload to be sent to the API. + Set ``state.main_json_data`` variable. + :param State state: Application state. + :param str image_string: Image data as Base64 encoded string. + """ + + with open(state.json_file, "r") as f: + json_data = json.load(f) + + if state.render["quick_mode"]: + json_data['steps'] = json_data.get('quick_steps', json_data['steps'] // 2) # use quick_steps setting, or halve steps if not set + + json_data['controlnet_units'][0]['input_image'] = image_string + json_data['controlnet_units'][0]['model'] = state.control_net["controlnet_model"] + json_data['controlnet_units'][0]['weight'] = state.control_net["controlnet_weight"] + if json_data['controlnet_units'][0].get('guidance_start', None) is None: + json_data['controlnet_units'][0]['guidance_start'] = 0.0 + json_data['controlnet_units'][0]['guidance_end'] = state.control_net["controlnet_guidance_end"] + json_data['controlnet_units'][0]['pixel_perfect'] = state.render["pixel_perfect"] + if state.render["use_invert_module"]: + json_data['controlnet_units'][0]['module'] = 'invert' + if not state.render["pixel_perfect"]: + json_data['controlnet_units'][0]['processor_res'] = min(state.render["width"], state.render["height"]) + json_data['hr_second_pass_steps'] = max(8, math.floor(int(json_data['steps']) * state.render["denoising_strength"])) # at least 8 steps + + if state.render["hr_scale"] > 1.0: + json_data['enable_hr'] = 'true' + else: + json_data['enable_hr'] = 'false' + + json_data['batch_size'] = state.render["batch_size"] + json_data['seed'] = state.gen_settings["seed"] + json_data['prompt'] = state.gen_settings["prompt"] + json_data['negative_prompt'] = state.gen_settings["negative_prompt"] + json_data['hr_scale'] = state.render["hr_scale"] + json_data['hr_upscaler'] = state.render["hr_upscaler"] + json_data['denoising_strength'] = state.render["denoising_strength"] + json_data['sampler_name'] = state.samplers["sampler"] + + if json_data.get('override_settings', None) is None: + json_data['override_settings'] = {} + + json_data['override_settings']['CLIP_stop_at_last_layers'] = state.render["clip_skip"] + + state["main_json_data"] = json_data + + +def get_img2img_json(state): + """ + Construct img2img JSON payload. + :param State state: Application state. + :return: JSON payload. + """ + + with open(state.json_file, "r") as f: + json_data = json.load(f) + + if os.path.splitext(state.img2img)[1] == '.psd': + psd = PSDImage.open(state.img2img) + im = psd.composite() + data = io.BytesIO() + im.save(data, format="png") + data = base64.b64encode(data.getvalue()).decode('utf-8') + json_data['width'] = im.width + json_data['height'] = im.height + else: + with Image.open(state.img2img, mode='r') as im: + data = io.BytesIO() + im.save(data, format=im.format) + data = base64.b64encode(data.getvalue()).decode('utf-8') + json_data['width'] = im.width + json_data['height'] = im.height + + json_data['init_images'] = [data] + + json_data['seed'] = state.gen_settings["seed"] + json_data['prompt'] = state.gen_settings["prompt"] + json_data['negative_prompt'] = state.gen_settings["negative_prompt"] + json_data['denoising_strength'] = state.render["denoising_strength"] + json_data['sampler_name'] = state.samplers["sampler"] + + if json_data.get('override_settings', None) is None: + json_data['override_settings'] = {} + + json_data['override_settings']['CLIP_stop_at_last_layers'] = state.render["clip_skip"] + + if state.render["quick_mode"]: + json_data['steps'] = json_data.get('quick_steps', json_data['steps'] // 2) # use quick_steps setting, or halve steps if not set + return json_data + + +# Type hinting imports: +from .state import State diff --git a/scripts/views/PygameView.py b/scripts/views/PygameView.py new file mode 100644 index 0000000..e26cb49 --- /dev/null +++ b/scripts/views/PygameView.py @@ -0,0 +1,1260 @@ +import functools +import gc +import os + +import pygame +import pygame.gfxdraw +import requests +import threading +import base64 +import io +import json +import time +import math +from PIL import Image, ImageOps +import tkinter as tk +from tkinter import filedialog, simpledialog +from scripts.common.utils import payload_submit, update_config, save_preset, update_size, new_random_seed +from scripts.common.cn_requests import fetch_controlnet_models, progress_request, fetch_detect_image, fetch_img2img, post_request +from scripts.common.output_files_utils import autosave_image, save_image +from scripts.common.state import State + + +class TextDialog(simpledialog.Dialog): + """ + Text input dialog. + """ + + def __init__(self, text, title, dialog_width=800, dialog_height=100): + self.text = text + self.dialog_width = dialog_width + self.dialog_height = dialog_height + self.result = None + super().__init__(None, title=title) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.quit() + gc.collect() + + def body(self, master): + self.geometry(f'{self.dialog_width}x{self.dialog_height}') + + self.e1 = tk.Text(master) + self.e1.insert("1.0", self.text) + self.e1.pack(padx=0, pady=0, fill=tk.BOTH) + + self.attributes("-topmost", True) + master.pack(fill=tk.BOTH, expand=True) + + return self.e1 + + def apply(self): + if "_"+self.e1.get("1.0", tk.INSERT)[-1:]+"_" == "_\n_": + p = self.e1.get("1.0", tk.INSERT)[:-1] + self.e1.get(tk.INSERT, tk.END) # remove new line inserted when validating the dialog with ENTER + else: + p = self.e1.get("1.0", tk.END) + self.result = p.strip("\n") + + +class PygameView: + """ + SdPaint Pygame interface + """ + + ACCEPTED_FILE_TYPES = ["png", "jpg", "jpeg", "bmp"] + + def __init__(self, img2img): + + self.state = State(img2img) + if self.state.img2img: + if not os.path.exists(self.state.img2img): + root = tk.Tk() + root.withdraw() + self.state.img2img = filedialog.askopenfilename() + + self.img2img_time = os.path.getmtime(self.state.img2img) + + with Image.open(self.state.img2img, mode='r') as im: + self.state.render["width"] = im.width + self.state.render["height"] = im.height + self.state.render["init_width"] = self.state.render["width"] * 1.0 + self.state.render["init_height"] = self.state.render["height"] * 1.0 + update_size(self.state) + + self.img2img_waiting = False + self.img2img_time = None + self.img2img_time_prev = None + + self.rendering = False + self.rendering_key = False + self.instant_render = False + self.image_click = False + self.pause_render = False + self.osd_always_on_text: str | None = None + self.progress = 0.0 + self.need_redraw = False + self.running = False + + self.last_detect_time = time.time() + self.osd_text = None + self.osd_text_display_start = None + + if not self.state.configuration["config"]['controlnet_models']: + fetch_controlnet_models(self.state) + + # Initialize Pygame + pygame.init() + self.clock = pygame.time.Clock() + + # Set up the display + self.fullscreen = False + self.screen = pygame.display.set_mode((self.state.render["width"] * (1 if self.state.img2img else 2), self.state.render["height"])) + self.display_caption = "Sd Paint" + pygame.display.set_caption(self.display_caption) + + # Setup text + self.font = pygame.font.SysFont(None, size=24) + self.font_bold = pygame.font.SysFont(None, size=24, bold=True) + self.text_input = "" + + # Set up the drawing surface + self.canvas = pygame.Surface((self.state.render["width"] * 2, self.state.render["height"])) + pygame.draw.rect(self.canvas, (255, 255, 255), (0, 0, self.state.render["width"] * (1 if self.state.img2img else 2), self.state.render["height"])) + + # Set up the brush + self.brush_size = {1: 2, 2: 2, 'e': 10} + self.brush_colors = { + 1: (0, 0, 0), # Left mouse button color + 2: (255, 255, 255), # Middle mouse button color + 'e': (255, 255, 255), # Eraser color + } + self.brush_color = self.brush_colors[1] + self.brush_pos = {1: None, 2: None, 'e': None} # type: dict[int|str, tuple[int, int]|None] + self.prev_pos = None + self.prev_pos2 = None + self.shift_pos = None + self.eraser_down = False + self.render_wait = 0.5 if not self.state.img2img else 0.0 # wait time max between 2 draw before launching the render + self.last_draw_time = time.time() + self.last_render_bytes: io.BytesIO | None = None + + # Define the cursor size and color + self.cursor_size = 1 + self.cursor_color = (0, 0, 0) + + # Init the default preset + save_preset(self.state, 'render', 0) + save_preset(self.state, 'controlnet', 0) + + def load_preset(self, preset_type, index): + """ + Load a preset values. + :param str preset_type: The preset type. ``[render, controlnet]`` + :param int index: The preset numeric keymap. + """ + + presets = self.state.presets["list"] + index = str(index) + + if presets[preset_type].get(index, None) is None: + return f"No {preset_type} preset {index}" + + preset = presets[preset_type][index] + + if index == '0': + text = f"Load default settings:" + + if preset_type == 'controlnet': + # prepend OSD output with render preset values for default settings display (both called successively) + for preset_field in self.state.presets["fields"]: + text += f"\n {preset_field[:1].upper()}{preset_field[1:].replace('_', ' ')} :: {presets['render'][index][preset_field]}" + else: + text = f"Load {preset_type} preset {index}:" + + # load preset + if preset_type == 'render': + for preset_field in self.state.presets["fields"]: + if preset.get(preset_field, None) is None: + continue + + self.state.render[preset_field] = preset[preset_field] + text += f"\n {preset_field[:1].upper()}{preset_field[1:].replace('_', ' ')} :: {preset[preset_field]}" + + elif preset_type == 'controlnet': + for preset_field in self.state.control_net["preset_fields"]: + if preset.get(preset_field, None) is None: + continue + self.state.control_net[preset_field] = preset[preset_field] + text += f"\n {preset_field[:1].upper()}{preset_field[1:].replace('_', ' ')} :: {preset[preset_field]}" + + update_size(self.state) + return text + + def interrupt_rendering(self): + """ + Interrupt current rendering. + """ + + response = requests.post(url=f'{self.state.server["url"]}/sdapi/v1/interrupt') + if response.status_code == 200: + self.osd(text="Interrupted rendering") + + def load_filepath_into_canvas(self, file_path): + """ + Load an image file on the sketch canvas. + + :param str file_path: Local image file path. + """ + self.canvas = pygame.Surface((self.state.render["width"] * (1 if self.state.img2img else 2), self.state.render["height"])) + pygame.draw.rect(self.canvas, (255, 255, 255), (0, 0, self.state.render["width"] * (1 if self.state.img2img else 2), self.state.render["height"])) + img = pygame.image.load(file_path) + img = pygame.transform.smoothscale(img, (self.state.render["width"], self.state.render["height"])) + self.canvas.blit(img, (self.state.render["width"], 0)) + + def finger_pos(self, finger_x, finger_y): + """ + Compute finger position on canvas. + :param float finger_x: Finger X position. + :param float finger_y: Finger Y position. + :return: Finger coordinates. + """ + + x = round(min(max(finger_x, 0), 1) * self.state.render["width"] * (1 if self.state.img2img else 2)) + y = round(min(max(finger_y, 0), 1) * self.state.render["height"]) + return x, y + + def save_file_dialog(self): + """ + Display save file dialog, then write the file. + """ + + root = tk.Tk() + root.withdraw() + file_path = filedialog.asksaveasfilename(defaultextension=".png") + + if file_path: + save_image(file_path, self.last_render_bytes) + self.save_sketch(file_path) + time.sleep(1) # add a 1-second delay + + def save_sketch(self, file_path): + """ + Save sketch canvas to a file. + :param str file_path: Render output file path. + """ + file_name, file_ext = os.path.splitext(file_path) + sketch_img = self.canvas.subsurface(pygame.Rect(self.state.render["width"], 0, self.state.render["width"], self.state.render["height"])).copy() + pygame.image.save(sketch_img, f"{file_name}-sketch{file_ext}") + + def load_file_dialog(self): + """ + Display loading file dialog, then load the image on the sketch canvas. + """ + + root = tk.Tk() + root.withdraw() + file_path = filedialog.askopenfilename() + if not file_path: + return + + extension = os.path.splitext(file_path)[1][1:].lower() + if extension in PygameView.ACCEPTED_FILE_TYPES: + self.load_filepath_into_canvas(file_path) + + def update_image(self, image_data): + """ + Redraw the image canvas. + :param str|bytes image_data: Base64 encoded image data, from API response. + """ + + # Decode base64 image data + if isinstance(image_data, str): + image_data = base64.b64decode(image_data) + + img_bytes = io.BytesIO(image_data) + img_surface = pygame.image.load(img_bytes) + + if self.state.autosave["images"]: + file_name = autosave_image(self.state, io.BytesIO(image_data)) + self.save_sketch(file_name) + self.last_render_bytes = io.BytesIO(image_data) # store rendered image in memory + + if self.state.render["soft_upscale"] != 1.0: + img_surface = pygame.transform.smoothscale(img_surface, (img_surface.get_width() * self.state.render["soft_upscale"], img_surface.get_height() * self.state.render["soft_upscale"])) + + self.canvas.blit(img_surface, (0, 0)) + self.need_redraw = True + + def update_batch_images(self, image_datas): + """ + Redraw the image canvas with multiple images. + :param list[str]|list[bytes] image_datas: Images data, if ``str`` type : base64 encoded from API response. + """ + + # Close old batch images + if len(self.state.render["batch_images"]): + for batch_image in self.state.render["batch_images"]: + image_bytes = batch_image.get('image', None) + if isinstance(image_bytes, io.BytesIO): + image_bytes.close() + + self.state.render["batch_images"] = [] + + to_autosave = [] + nb = math.ceil(math.sqrt(len(image_datas))) + i, j, batch_index = 0, 0, 1 + for image_data in image_datas: + pos = (i * self.state.render["width"] // nb, j * self.state.render["height"] // nb) + + # Decode base64 image data + if isinstance(image_data, str): + image_data = base64.b64decode(image_data) + + img_bytes = io.BytesIO(image_data) + img_surface = pygame.image.load(img_bytes) + + if (i, j) == (0, 0): + self.last_render_bytes = io.BytesIO(image_data) # store first rendered image in memory + + if self.state.render["soft_upscale"] != 1.0: + img_surface = pygame.transform.smoothscale(img_surface, (img_surface.get_width() * self.state.render["soft_upscale"] // nb, img_surface.get_height() * self.state.render["soft_upscale"] // nb)) + + if self.state.autosave["images"]: + to_autosave.append(io.BytesIO(image_data)) + + self.state.render["batch_images"].append({ + "seed": self.state.gen_settings["seed"] + batch_index - 1, + "image": io.BytesIO(image_data), + "coord": (pos[0], pos[1], img_surface.get_width(), img_surface.get_height()) + }) + + self.canvas.blit(img_surface, pos) + + # increase indices + i = (i + 1) % nb + if i == 0: + j = (j + 1) % nb + batch_index += 1 + + if to_autosave: + file_names = autosave_image(self.state, to_autosave) + for file_name in file_names: + self.save_sketch(file_name) + + self.need_redraw = True + + def select_batch_image(self, pos): + """ + Select a batch image by clicking on it. + :param list[int]|tuple[int] pos: The event position. + """ + + if not len(self.state.render["batch_images"]): + return + + for batch_image in self.state.render["batch_images"]: + if batch_image['coord'][0] <= pos[0] < batch_image['coord'][0] + batch_image['coord'][2] \ + and batch_image['coord'][1] <= pos[1] < batch_image['coord'][1] + batch_image['coord'][3]: + + self.need_redraw = True + self.osd(text_time=f"Select batch image seed {batch_image['seed']}") + + if batch_image.get('image', None): + self.update_image(batch_image['image'].getbuffer().tobytes()) + + self.state.gen_settings["seed"] = batch_image['seed'] + + self.toggle_batch_mode() + + break + + def img2img_submit(self, force=False): + """ + Read the ``img2img`` file if modified since last render, check every 1s. Call the API to render if needed. + :param bool force: Force the rendering, even if the file is not modified. + """ + self.img2img_waiting = False + + self.img2img_time = os.path.getmtime(self.state.img2img) + if self.img2img_time != self.img2img_time_prev or force: + self.img2img_time_prev = self.img2img_time + + self.state.server["busy"] = True + + t = threading.Thread(target=self.progress_bar) + t.start() + + response = fetch_img2img(self.state) + if response["status_code"] == 200: + return_img = response["image"] + self.update_image(return_img) + r_info = json.loads(response['info']) + return_prompt = r_info['prompt'] + return_seed = r_info['seed'] + self.display_caption = f"Sd Paint | Seed: {return_seed} | Prompt: {return_prompt}" + else: + self.osd(text=f"Error code returned: HTTP {response['status_code']}") + + self.state.server["busy"] = False + + if not self.img2img_waiting and self.running: + self.img2img_waiting = True + time.sleep(1.0) + self.img2img_submit() + + def progress_bar(self): + """ + Update the progress bar every 0.25s + """ + if not self.state.server["busy"]: + return + + progress_json = progress_request(self.state) + if progress_json.get("status_code", None): + self.osd(text=f"Error code returned: HTTP {progress_json['status_code']}") + self.progress = progress_json.get('progress', None) + # if progress is not None and progress > 0.0: + # print(f"{progress*100:.0f}%") + + if self.state.server["busy"]: + time.sleep(0.25) + self.progress_bar() + + def draw_osd_text(self, text, rect, color=(255, 255, 255), shadow_color=(0, 0, 0), distance=1, right_align=False): + """ + Draw OSD text with outline. + :param str text: The text to draw. + :param list[int]|tuple[int]|pygame.Rect rect: Destination rect. + :param tuple|int color: Text color. + :param tuple|int shadow_color: Outline color. + :param int distance: Outline/shadow size. + :param bool right_align: Align text to the right. + """ + + align_offset = 0 + if right_align: + align_offset = -1 + + text_surface = self.font.render(text, True, shadow_color) + self.screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset + distance, rect[1] + distance, rect[2], rect[3])) + self.screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset - distance, rect[1] + distance, rect[2], rect[3])) + self.screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset + distance, rect[1] - distance, rect[2], rect[3])) + self.screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset - distance, rect[1] - distance, rect[2], rect[3])) + text_surface = self.font.render(text, True, color) + self.screen.blit(text_surface, (rect[0] + text_surface.get_width() * align_offset, rect[1], rect[2], rect[3])) + + def osd(self, **kwargs): + """ + OSD display: progress bar and text messages. + + :param kwargs: Accepted parameters : ``progress, text, text_time, need_redraw`` + """ + + osd_size = (128, 20) + osd_margin = 10 + osd_progress_pos = (self.state.render["width"] * (0 if self.state.img2img else 1) + osd_margin, osd_margin) # top left of canvas + # osd_pos = (width*(1 if img2img else 2) // 2 - osd_size [0] // 2, osd_margin) # top center + # osd_progress_pos = (width*(1 if img2img else 2) - osd_size[0] - osd_margin, height - osd_size[1] - osd_margin) # bottom right + + osd_dot_size = osd_size[1] // 2 + # osd_dot_pos = (width*(0 if img2img else 1) + osd_margin, osd_margin, osd_dot_size, osd_dot_size) # top left + osd_dot_pos = (self.state.render["width"]*(1 if self.state.img2img else 2) - osd_dot_size * 2 - osd_margin, osd_margin, osd_dot_size, osd_dot_size) # top right + + osd_text_pos = (self.state.render["width"]*(0 if self.state.img2img else 1) + osd_margin, osd_margin) # top left of canvas + # osd_text_pos = (width*(0 if img2img else 1) + osd_margin, height - osd_size[1] - osd_margin) # bottom left of canvas + osd_text_offset = 0 + + osd_text_split_offset = 250 + + self.progress = kwargs.get('progress', self.progress) # type: float + text = kwargs.get('text', self.osd_text) # type: str + text_time = kwargs.get('text_time', 2.0) # type: float + self.need_redraw = kwargs.get('need_redraw', self.need_redraw) # type: bool + self.osd_always_on_text = kwargs.get('always_on', self.osd_always_on_text) + + if self.rendering or (self.state.server["busy"] and self.progress is not None and self.progress < 0.02): + rendering_dot_surface = pygame.Surface(osd_size, pygame.SRCALPHA) + + pygame.draw.circle(rendering_dot_surface, (0, 0, 0), (osd_dot_size + 2, osd_dot_size + 2), osd_dot_size - 2) + pygame.draw.circle(rendering_dot_surface, (0, 200, 160), (osd_dot_size, osd_dot_size), osd_dot_size - 2) + self.screen.blit(rendering_dot_surface, osd_dot_pos) + + if self.progress is not None and self.progress > 0.01: + self.need_redraw = True + + # progress bar + progress_surface = pygame.Surface(osd_size, pygame.SRCALPHA) + pygame.draw.rect(progress_surface, (0, 0, 0), pygame.Rect(2, 2, math.floor(osd_size[0] * self.progress), osd_size[1])) + pygame.draw.rect(progress_surface, (0, 200, 160), pygame.Rect(0, 0, math.floor(osd_size[0] * self.progress), osd_size[1] - 2)) + + self.screen.blit(progress_surface, pygame.Rect(osd_progress_pos[0], osd_progress_pos[1], osd_size[0], osd_size[1])) + + # progress text + self.draw_osd_text(f"{self.progress * 100:.0f}%", (osd_size[0] - osd_margin + osd_progress_pos[0], 3 + osd_progress_pos[1], osd_size[0], osd_size[1]), right_align=True) + + osd_text_offset = osd_size[1] + osd_margin + + if self.osd_always_on_text: + self.need_redraw = True + + # OSD always-on text + for line in self.osd_always_on_text.split('\n'): + self.need_redraw = True + + if '::' in line: + line, line_value = line.split('::') + line = line.rstrip(' ') + line_value = line_value.lstrip(' ') + else: + line_value = None + + self.draw_osd_text(line, (osd_text_pos[0], osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) + if line_value: + self.draw_osd_text(line_value, (osd_text_pos[0] + osd_text_split_offset, osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) + + osd_text_offset += osd_size[1] + + if text: + self.need_redraw = True + + # OSD text + if self.osd_text_display_start is None or text != self.osd_text: + self.osd_text_display_start = time.time() + self.osd_text = text + + for line in self.osd_text.split('\n'): + self.need_redraw = True + + if '::' in line: + line, line_value = line.split('::') + line = line.rstrip(' ') + line_value = line_value.lstrip(' ') + else: + line_value = None + + self.draw_osd_text(line, (osd_text_pos[0], osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) + if line_value: + self.draw_osd_text(line_value, (osd_text_pos[0] + osd_text_split_offset, osd_text_pos[1] + osd_text_offset, osd_size[0], osd_size[1])) + + osd_text_offset += osd_size[1] + + if time.time() - self.osd_text_display_start > text_time: + self.osd_text = None + self.osd_text_display_start = None + + def get_image_string_from_pygame(self): + """ + Get base64 encoded image string from canvas. + :return: The encoder image. + """ + img = self.canvas.subsurface(pygame.Rect(self.state.render["width"], 0, self.state.render["width"], self.state.render["height"])).copy() + + if not self.state.render["use_invert_module"]: + # Convert the Pygame surface to a PIL image + pil_img = Image.frombytes('RGB', img.get_size(), pygame.image.tostring(img, 'RGB')) + + # Invert the colors of the PIL image + pil_img = ImageOps.invert(pil_img) + + # Convert the PIL image back to a Pygame surface + img = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode).convert_alpha() + + # Save the inverted image as base64-encoded data + data = io.BytesIO() + pygame.image.save(img, data) + return base64.b64encode(data.getvalue()).decode('utf-8') + + def send_request(self): + """ + Send the API request. + """ + + response = post_request(self.state) + if response["status_code"] == 200: + if response.get("image", None): + self.update_image(response["image"]) + elif response.get("batch_images", None): + self.update_batch_images(response["batch_images"]) + + r_info = json.loads(response['info']) + return_prompt = r_info['prompt'] + return_seed = r_info['seed'] + self.display_caption = f"Sd Paint | Seed: {return_seed} | Prompt: {return_prompt}" + else: + self.osd(text=f"Error code returned: HTTP {response['status_code']}") + self.state.server["busy"] = False + + def render(self): + """ + Call the API to launch the rendering, if another rendering is not in progress. + """ + if time.time() - self.last_draw_time < self.render_wait and not self.instant_render: + time.sleep(0.25) + self.render() + return + + self.instant_render = False + + if not self.state.server["busy"]: + self.state.server["busy"] = True + + if not self.state.img2img: + image_string = self.get_image_string_from_pygame() + payload_submit(self.state, image_string) + t = threading.Thread(target=self.send_request) + t.start() + t = threading.Thread(target=self.progress_bar) + t.start() + else: + t = threading.Thread(target=functools.partial(self.img2img_submit, True)) + t.start() + + @staticmethod + def get_angle(pos1, pos2): + """ + Get the angle between two position. + :param tuple[int]|list[int] pos1: First position. + :param tuple[int]|list[int] pos2: Second position. + :return: radians, degrees, cos, sin + """ + + dx = pos1[0] - pos2[0] + dy = pos1[1] - pos2[1] + rads = math.atan2(-dy, dx) + rads %= 2 * math.pi + + return rads, math.degrees(rads), math.cos(rads), math.sin(rads) + + def brush_stroke(self, event, button, pos): + """ + Draw the brush stroke. + :param pygame.event.Event|dict event: The pygame event. + :param int|str button: The active button. + :param tuple[int]|list[int] pos: The brush current position. + """ + + if self.prev_pos is None or (abs(event.pos[0] - self.prev_pos[0]) < self.brush_size[button] // 4 and abs(event.pos[1] - self.prev_pos[1]) < self.brush_size[button] // 4): + # Slow brush stroke, draw circles + pygame.draw.circle(self.canvas, self.brush_colors[button], event.pos, self.brush_size[button]) + + elif not self.prev_pos2 or self.brush_size[button] < 4: + # Draw a simple polygon for small brush sizes + pygame.draw.polygon(self.canvas, self.brush_colors[button], [self.prev_pos, event.pos], self.brush_size[button] * 2) + + else: + # Draw a complex shape with gfxdraw for bigger bush sizes to avoid gaps + angle_prev = self.get_angle(self.prev_pos, self.prev_pos2) + angle = self.get_angle(event.pos, self.prev_pos) + + offset_pos_prev = [(self.brush_size[button] * angle_prev[3]), (self.brush_size[button] * angle_prev[2])] + offset_pos = [(self.brush_size[button] * angle[3]), (self.brush_size[button] * angle[2])] + pygame.gfxdraw.filled_polygon(self.canvas, [ + (self.prev_pos2[0] - offset_pos_prev[0], self.prev_pos2[1] - offset_pos_prev[1]), + (self.prev_pos[0] - offset_pos[0], self.prev_pos[1] - offset_pos[1]), + (event.pos[0] - offset_pos[0], event.pos[1] - offset_pos[1]), + (event.pos[0] + offset_pos[0], event.pos[1] + offset_pos[1]), + (self.prev_pos[0] + offset_pos[0], self.prev_pos[1] + offset_pos[1]), + (self.prev_pos2[0] + offset_pos_prev[0], self.prev_pos2[1] + offset_pos_prev[1]) + ], self.brush_colors[button]) + + self.prev_pos2 = self.prev_pos + self.prev_pos = event.pos + + @property + def shift_down(self): + return pygame.key.get_mods() & pygame.KMOD_SHIFT + + @property + def ctrl_down(self): + return pygame.key.get_mods() & pygame.KMOD_CTRL + + @property + def alt_down(self): + return pygame.key.get_mods() & pygame.KMOD_ALT + + def controlnet_detect(self, detector): + """ + Call ControlNet active detector on the last rendered image, replace the canvas sketch by the detector result. + :param str detector: The detector to apply. + """ + img = self.canvas.subsurface(pygame.Rect(0, 0, self.state.render["width"], self.state.render["height"])).copy() + + # Convert the Pygame surface to a PIL image + pil_img = Image.frombytes('RGB', img.get_size(), pygame.image.tostring(img, 'RGB')) + + # Invert the colors of the PIL image + pil_img = ImageOps.invert(pil_img) + + # Convert the PIL image back to a Pygame surface + img = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode).convert_alpha() + + # Save the inverted image as base64-encoded data + data = io.BytesIO() + pygame.image.save(img, data) + data = base64.b64encode(data.getvalue()).decode('utf-8') + + response = fetch_detect_image(self.state, detector, data, img.get_width(), img.get_height()) + if response["status_code"] == 200: + return_img = response["image"] + img_bytes = io.BytesIO(base64.b64decode(return_img)) + pil_img = Image.open(img_bytes) + pil_img = ImageOps.invert(pil_img) + pil_img = pil_img.convert('RGB') + img_surface = pygame.image.fromstring(pil_img.tobytes(), pil_img.size, pil_img.mode) + + self.canvas.blit(img_surface, (self.state.render["width"], 0)) + else: + self.osd(text=f"Error code returned: HTTP {response['status_code']}") + + def display_configuration(self, wrap=True): + """ + Display configuration on screen. + :param bool wrap: Wrap long text. + """ + + fields = [ + '--Prompt', + 'state/gen_settings/prompt', + 'state/gen_settings/negative_prompt', + 'state/gen_settings/seed', + '--Render', + 'state/render/render_size', + 'settings.steps', + 'settings.cfg_scale', + 'state/render/hr_scale', + 'state/render/hr_upscaler', + 'state/render/denoising_strength', + 'state/render/clip_skip', + '--ControlNet', + 'state/control_net/controlnet_model', + 'state/control_net/controlnet_weight', + 'state/control_net/controlnet_guidance_end', + 'state/render/pixel_perfect', + '--Misc', + 'state/detectors/detector' + ] + + if wrap and self.state.render["width"] < 800: + wrap = 50 + elif wrap: + wrap = 80 + + text = '' + + for field in fields: + if field == 'settings.steps' and self.state.render["quick_mode"]: + field = 'settings.quick_steps' + + # Display separator + if field.startswith('--'): + text += '\n'+field[2:]+'\n' + continue + + # Field value + label = '' + value = '' + + if '.' in field: + field = field.split('.') + var = globals().get(field[0], None) + if var is None: + continue + + if isinstance(var, dict) and var.get(field[1], None) is not None: + label = field[1] + value = var.get(field[1]) + elif (isinstance(var, list) or isinstance(var, tuple)) and field[1].isnumeric() and int(field[1]) < len(var): + label = field[0] + value = var[int(field[1])] + elif getattr(var, field[1], None) is not None: + label = field[1] + value = getattr(var, field[1]) + else: + if field.startswith('state/'): + field_components = field.replace('state/', '').split('/') + label = field_components[1] + field_value = getattr(self.state, field_components[0])[field_components[1]] + else: + label = field + field_value = globals().get(field, None) + + if 'size' in label and isinstance(field_value, tuple) and len(field_value) == 2: + field_value = f"{field_value[0]}x{field_value[1]}" + + if field_value is not None: + value = field_value + + if label and value is not None: + value = str(value) + label = label.replace('_', ' ') + if label.endswith('prompt'): + value = value.replace(', ', ',').replace(',', ', ') # nicer prompt display + + # wrap text + if wrap and len(value) > wrap: + new_value = '' + to_wrap = 0 + for i in range(len(value)): + if i % wrap == wrap - 1: + to_wrap = i + + if to_wrap and value[i] in [' ', ')'] or (to_wrap and i - to_wrap > 5): # try to wrap after space + new_value += value[i]+'\n::' + to_wrap = 0 + continue + + new_value += value[i] + + value = new_value + + text += f" {label} :: {value}" + + text += '\n' + + self.osd(always_on=text.strip('\n')) + + def toggle_batch_mode(self, cycle=False): + """ + Toggle batch mode on/off. Alter the setting of HR fix if needed. + :param bool|int cycle: Cycle the batch size value. + """ + + batch_size = self.state.render["batch_size"] + if cycle: + self.rendering_key = True + batch_sizes = self.state.render["batch_sizes"] + self.state.render["batch_size"] = batch_sizes[(batch_sizes.index(self.state.render["batch_size"]) + 1) % len(batch_sizes)] + else: + self.rendering = True + if self.state.render["batch_size"] != 1: + self.state.render["batch_size_prev"] = batch_size + self.state.render["batch_size"] = 1 + else: + self.state.render["batch_size"] = self.state.render["batch_size_prev"] + + if self.state.render["batch_size"] == 1: + self.state.render["hr_scale"] = self.state.render["batch_hr_scale_prev"] + update_size(self.state) + self.osd(text=f"Batch rendering: off") + else: + self.state.render["batch_hr_scale_prev"] = self.state.render["hr_scale"] + self.state.render["hr_scale"] = 1.0 + update_size(self.state) + self.osd(text=f"Batch rendering size: {self.state.render['batch_size']}") + + def main(self): + # Initial img2img call + if self.state.img2img: + t = threading.Thread(target=self.img2img_submit) + t.start() + + # Set up the main loop + self.running = True + self.need_redraw = True + + while self.running: + self.rendering = False + + # Handle events + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.running = False + + elif event.type == pygame.MOUSEBUTTONDOWN or event.type == pygame.FINGERDOWN: + # Handle brush stroke start and modifiers + if event.type == pygame.FINGERDOWN: + event.button = 1 + event.pos = self.finger_pos(event.x, event.y) + + if event.pos[0] < self.state.render["width"]: + self.image_click = True # clicked on the image part + if self.state.render["batch_size"] != 1 and len(self.state.render["batch_images"]): + self.select_batch_image(event.pos) + else: + self.need_redraw = True + self.last_draw_time = time.time() + + brush_key = event.button + if self.eraser_down: + brush_key = 'e' + + if brush_key in self.brush_colors: + self.brush_pos[brush_key] = event.pos + elif event.button == 4: # scroll up + self.brush_size[1] = max(1, self.brush_size[1] + 1) + self.brush_size[2] = max(1, self.brush_size[2] + 1) + self.osd(text=f"Brush size {self.brush_size[1]}") + + elif event.button == 5: # scroll down + self.brush_size[1] = max(1, self.brush_size[1] - 1) + self.brush_size[2] = max(1, self.brush_size[2] - 1) + self.osd(text=f"Brush size {self.brush_size[1]}") + + if self.shift_down and self.brush_pos[brush_key] is not None: + if self.shift_pos is None: + self.shift_pos = self.brush_pos[brush_key] + else: + pygame.draw.polygon(self.canvas, self.brush_colors[brush_key], [self.shift_pos, self.brush_pos[brush_key]], self.brush_size[brush_key] * 2) + self.shift_pos = self.brush_pos[brush_key] + + elif event.type == pygame.MOUSEBUTTONUP or event.type == pygame.FINGERUP: + # Handle brush stoke end + self.last_draw_time = time.time() + + if event.type == pygame.FINGERUP: + event.button = 1 + event.pos = self.finger_pos(event.x, event.y) + + if not self.image_click: + self.need_redraw = True + self.rendering = True + + if event.button in self.brush_colors or self.eraser_down: + brush_key = event.button + if self.eraser_down: + brush_key = 'e' + + if self.brush_size[brush_key] >= 4 and getattr(event, 'pos', None) is not None: + pygame.draw.circle(self.canvas, self.brush_colors[brush_key], event.pos, self.brush_size[brush_key]) + + self.brush_pos[brush_key] = None + self.prev_pos = None + self.brush_color = self.brush_colors[brush_key] + + self.image_click = False # reset image click detection + + elif event.type == pygame.MOUSEMOTION or event.type == pygame.FINGERMOTION: + # Handle drawing brush strokes + if event.type == pygame.FINGERMOTION: + event.pos = self.finger_pos(event.x, event.y) + + if not self.image_click: + self.need_redraw = True + for button, pos in self.brush_pos.items(): + if pos is not None and button in self.brush_colors: + self.last_draw_time = time.time() + self.brush_stroke(event, button, pos) # do the brush stroke + + elif event.type == pygame.KEYDOWN: + # DBG key & modifiers + # print(f"key down {event.key}, modifiers:") + # print(f" shift: {pygame.key.get_mods() & pygame.KMOD_SHIFT}") + # print(f" ctrl: {pygame.key.get_mods() & pygame.KMOD_CTRL}") + # print(f" alt: {pygame.key.get_mods() & pygame.KMOD_ALT}") + + # Handle keyboard shortcuts + self.need_redraw = True + self.last_draw_time = time.time() + self.rendering = False + + event.button = 1 + if event.key == pygame.K_UP: + self.rendering = True + self.instant_render = True + self.state.gen_settings["seed"] = self.state.gen_settings["seed"] + self.state.render["batch_size"] + update_config(self.state.json_file, write=self.state.autosave["seed"], values={'seed': self.state.gen_settings["seed"]}) + self.osd(text=f"Seed: {self.state['gen_settings']['seed']}") + + elif event.key == pygame.K_DOWN: + self.rendering = True + self.instant_render = True + self.state.gen_settings["seed"] = self.state.gen_settings["seed"] - self.state.render["batch_size"] + update_config(self.state.json_file, write=self.state.autosave["seed"], values={'seed': self.state.gen_settings["seed"]}) + self.osd(text=f"Seed: {self.state['gen_settings']['seed']}") + + elif event.key == pygame.K_n: + if self.ctrl_down: + with TextDialog(self.state.gen_settings["seed"], title="Seed", dialog_width=200, dialog_height=30) as dialog: + if dialog.result and dialog.result.isnumeric(): + self.osd(text=f"Seed: {dialog.result}") + self.state.gen_settings["seed"] = int(dialog.result) + update_config(self.state.json_file, write=self.state.autosave["seed"], values={'seed': self.state.gen_settings["seed"]}) + self.rendering = True + self.instant_render = True + else: + self.rendering = True + self.instant_render = True + self.state.gen_settings["seed"] = new_random_seed(self.state) + update_config(self.state.json_file, write=self.state.autosave["seed"], values={'seed': self.state.gen_settings["seed"]}) + self.osd(text=f"Seed: {self.state['gen_settings']['seed']}") + + elif event.key == pygame.K_c: + if self.shift_down: + self.rendering_key = True + self.state.render["clip_skip"] -= 1 + self.state.render["clip_skip"] = (self.state.render["clip_skip"] + 1) % 2 + self.state.render["clip_skip"] += 1 + self.osd(text=f"CLIP skip: {self.state['render']['clip_skip']}") + else: + self.display_configuration() + + elif event.key == pygame.K_m and self.state.control_net["controlnet_models"]: + if self.shift_down: + self.rendering_key = True + controlnet_models = self.state.control_net["controlnet_models"] + controlnet_model = self.state.control_net["controlnet_model"] + controlnet_model = controlnet_models[(controlnet_models.index(controlnet_model) + 1) % len(controlnet_models)] + self.state.control_net["controlnet_model"] = controlnet_model + self.osd(text=f"ControlNet model: {controlnet_model}") + + elif event.key == pygame.K_i: + if self.ctrl_down: + self.interrupt_rendering() + elif event.key == pygame.K_h: + if self.shift_down: + self.rendering_key = True + self.state.render["hr_scale"] = self.state.render["hr_scales"][(self.state.render["hr_scales"].index(self.state.render["hr_scale"])+1) % len(self.state.render["hr_scales"])] + else: + self.rendering = True + if self.state.render["hr_scale"] != 1.0: + self.state.render["hr_scale_prev"] = self.state.render["hr_scale"] + self.state.render["hr_scale"] = 1.0 + else: + self.state.render["hr_scale"] = self.state.render["hr_scale_prev"] + + if self.state.render["hr_scale"] == 1.0: + self.osd(text="HR scale: off") + else: + self.osd(text=f"HR scale: {self.state.render['hr_scale']}") + + update_size(self.state, hr_scale=self.state.render["hr_scale"]) + + elif event.key in (pygame.K_KP_ENTER, pygame.K_RETURN): + self.rendering = True + self.instant_render = True + self.osd(text=f"Rendering") + + elif event.key == pygame.K_q: + self.rendering = True + self.instant_render = True + self.state.render["quick_mode"] = not self.state.render["quick_mode"] + if self.state.render["quick_mode"]: + self.osd(text=f"Quick render: on") + self.state.render["hr_scale_prev"] = self.state.render["hr_scale"] + self.state.render["hr_scale"] = 1.0 + else: + self.osd(text=f"Quick render: off") + self.state.render["hr_scale"] = self.state.render["hr_scale_prev"] + + update_size(self.state, hr_scale=self.state.render["hr_scale"]) + + elif event.key == pygame.K_a: + self.state.autosave["images"] = not self.state.autosave["images"] + self.osd(text=f"Autosave images: {'on' if self.state['autosave']['images'] else 'off'}") + + elif event.key == pygame.K_o: + if self.ctrl_down: + self.rendering = True + self.instant_render = True + self.load_file_dialog() + + elif event.key in (pygame.K_KP0, pygame.K_KP1, pygame.K_KP2, pygame.K_KP3, pygame.K_KP4, + pygame.K_KP5, pygame.K_KP6, pygame.K_KP7, pygame.K_KP8, pygame.K_KP9): + keymap = { + pygame.K_KP0: 0, + pygame.K_KP1: 1, + pygame.K_KP2: 2, + pygame.K_KP3: 3, + pygame.K_KP4: 4, + pygame.K_KP5: 5, + pygame.K_KP6: 6, + pygame.K_KP7: 7, + pygame.K_KP8: 8, + pygame.K_KP9: 9 + } + + if self.ctrl_down: + if event.key != pygame.K_KP0: + preset_info = save_preset(self.state, 'controlnet' if self.alt_down else 'render', keymap.get(event.key)) + if preset_info['index'] == '0': + self.osd(text=f"Save {preset_info['preset_type']} preset {preset_info['index']}") + else: + self.rendering = True + self.instant_render = True + + if event.key == pygame.K_KP0: + # Reset both render & controlnet settings if keypad 0 + text = self.load_preset('render', keymap.get(event.key)) + self.osd(text=text, text_time=4.0) + text = self.load_preset('controlnet', keymap.get(event.key)) + self.osd(text=text, text_time=4.0) + else: + text = self.load_preset('controlnet' if self.alt_down else 'render', keymap.get(event.key)) + self.osd(text=text, text_time=4.0) + + elif event.key == pygame.K_p: + if self.ctrl_down: + self.pause_render = not self.pause_render + + if self.pause_render: + self.osd(text=f"On-demand rendering (ENTER to render)") + + else: + self.rendering = True + self.instant_render = True + self.osd(text=f"Dynamic rendering") + + elif self.alt_down: + with TextDialog(self.state.gen_settings["negative_prompt"], title="Negative prompt") as dialog: + if dialog.result: + self.osd(text=f"New negative prompt: {dialog.result}") + self.state.gen_settings["negative_prompt"] = dialog.result + update_config(self.state.json_file, write=self.state.autosave["negative_prompt"], values={'negative_prompt': self.state.gen_settings["negative_prompt"]}) + self.rendering = True + self.instant_render = True + else: + with TextDialog(self.state.gen_settings["prompt"], title="Prompt") as dialog: + if dialog.result: + self.osd(text=f"New prompt: {dialog.result}") + self.state.gen_settings["prompt"] = dialog.result + update_config(self.state.json_file, write=self.state.autosave["prompt"], values={'prompt': self.state.gen_settings["prompt"]}) + self.rendering = True + self.instant_render = True + + elif event.key == pygame.K_BACKSPACE: + self.rendering = True + self.instant_render = True + pygame.draw.rect(self.canvas, (255, 255, 255), (self.state.render["width"], 0, self.state.render["width"], self.state.render["height"])) + + elif event.key == pygame.K_s: + if self.ctrl_down: + self.save_file_dialog() + elif self.shift_down: + self.rendering_key = True + samplers = self.state.samplers["list"] + self.state.samplers["sampler"] = samplers[(samplers.index(self.state.samplers["sampler"]) + 1) % len(samplers)] + self.osd(text=f"Sampler: {self.state['samplers']['sampler']}") + + elif event.key == pygame.K_e: + self.eraser_down = True + + elif event.key == pygame.K_t: + if self.shift_down: + if self.render_wait == 2.0: + self.render_wait = 0.0 + self.osd(text="Render wait: off") + else: + self.render_wait += 0.5 + self.osd(text=f"Render wait: {self.render_wait}s") + + elif event.key == pygame.K_u: + if self.shift_down: + self.rendering_key = True + hr_upscalers = self.state.render["hr_upscalers"] + hr_upscaler = self.state.render["hr_upscaler"] + print(hr_upscaler, hr_upscalers) + hr_upscaler = hr_upscalers[(hr_upscalers.index(hr_upscaler) + 1) % len(hr_upscalers)] + self.state.render["hr_upscaler"] = hr_upscaler + self.osd(text=f"HR upscaler: {hr_upscaler}") + + elif event.key == pygame.K_b: + self.toggle_batch_mode(cycle=self.shift_down) + + elif event.key == pygame.K_w: + if self.shift_down: + self.rendering_key = True + controlnet_weights = self.state.control_net["controlnet_weights"] + controlnet_weight = self.state.control_net["controlnet_weight"] + controlnet_weight = controlnet_weights[(controlnet_weights.index(controlnet_weight) + 1) % len(controlnet_weights)] + self.state.control_net["controlnet_weight"] = controlnet_weight + self.osd(text=f"ControlNet weight: {controlnet_weight}") + + elif event.key == pygame.K_g: + if self.shift_down and self.ctrl_down: + self.rendering_key = True + self.state.render["pixel_perfect"] = not self.state.render["pixel_perfect"] + self.osd(text=f"ControlNet pixel perfect mode: {'on' if self.state.render['pixel_perfect'] else 'off'}") + elif self.shift_down: + self.rendering_key = True + controlnet_guidance_ends = self.state.control_net["controlnet_guidance_ends"] + controlnet_guidance_end = self.state.control_net["controlnet_guidance_end"] + controlnet_guidance_end = controlnet_guidance_ends[(controlnet_guidance_ends.index(controlnet_guidance_end) + 1) % len(controlnet_guidance_ends)] + self.state.control_net["controlnet_guidance_end"] = controlnet_guidance_end + self.osd(text=f"ControlNet guidance end: {controlnet_guidance_end}") + + elif event.key == pygame.K_d: + if self.ctrl_down: + if self.shift_down: + # cycle detectors + self.state.detectors["detector"] = self.state.detectors["list"][(self.state.detectors["list"].index(self.state.detectors["detector"])+1) % len(self.state.detectors["list"])] + self.osd(text=f"ControlNet detector: {self.state.detectors['detector']}") + else: + self.osd(text=f"Detect {self.state.detectors['detector']}") + detector = str(self.state.detectors['detector']) + + t = threading.Thread(target=functools.partial(self.controlnet_detect, detector)) + t.start() + elif self.shift_down: + self.rendering_key = True + denoising_strengths = self.state.render["denoising_strengths"] + denoising_strength = self.state.render["denoising_strength"] + denoising_strength = denoising_strengths[(denoising_strengths.index(denoising_strength) + 1) % len(denoising_strengths)] + self.state.render["denoising_strength"] = denoising_strength + if self.state.img2img: + self.osd(text=f"Denoising: {denoising_strength}") + else: + self.osd(text=f"HR denoising: {denoising_strength}") + + elif event.key == pygame.K_f: + self.fullscreen = not self.fullscreen + if self.fullscreen: + pygame.display.set_mode((0, 0), pygame.FULLSCREEN) + else: + pygame.display.set_mode((self.state.render["width"]*2, self.state.render["height"])) + + elif event.key in (pygame.K_ESCAPE, pygame.K_x): + self.running = False + pygame.quit() + exit(0) + + elif event.type == pygame.KEYUP: + # Handle special keys release + if event.key == pygame.K_e: + self.eraser_down = False + self.brush_pos['e'] = None + + elif event.key in (pygame.K_LSHIFT, pygame.K_RSHIFT): + if self.rendering_key: + self.rendering = True + self.rendering_key = False + self.shift_pos = None + + elif event.key in (pygame.K_c,): + # Remove OSD always-on text + self.need_redraw = True + self.osd(always_on=None) + + # Call image render + if (self.rendering and not self.pause_render) or self.instant_render: + t = threading.Thread(target=self.render) + t.start() + + # Draw the canvas and brushes on the screen + self.screen.blit(self.canvas, (0, 0)) + + # Create a new surface with a circle + cursor_size = self.brush_size[1]*2 + cursor_surface = pygame.Surface((cursor_size, cursor_size), pygame.SRCALPHA) + pygame.draw.circle(cursor_surface, self.cursor_color, (cursor_size // 2, cursor_size // 2), cursor_size // 2) + + # Blit the cursor surface onto the screen surface at the position of the mouse + mouse_pos = pygame.mouse.get_pos() + self.screen.blit(cursor_surface, (mouse_pos[0] - cursor_size // 2, mouse_pos[1] - cursor_size // 2)) + + # Handle OSD + self.osd() + + # Update the display + if self.need_redraw: + pygame.display.flip() + pygame.display.set_caption(self.display_caption) + self.need_redraw = False + + # Set max FPS + self.clock.tick(120) + + # Clean up Pygame + pygame.quit() diff --git a/start-img2img.sh b/start-img2img.sh new file mode 100755 index 0000000..1d736ff --- /dev/null +++ b/start-img2img.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +[ -d venv ] || python -m venv venv + +source ./venv/bin/activate + +pip install -r requirements.txt + +python SdPaint.py --img2img "$@" + +deactivate \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9238ad6 --- /dev/null +++ b/start.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +[ -d venv ] || python -m venv venv + +source ./venv/bin/activate + +pip install -r requirements.txt + +python SdPaint.py + +deactivate \ No newline at end of file