diff --git a/TODO.md b/TODO.md index 8d52a4bf2..9369e387c 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,8 @@ Tech that can be integrated as part of the core workflow... - [Use scripts from API](https://github.com/AUTOMATIC1111/stable-diffusion-webui/pull/6469) - [Aesthetic Gradients](https://github.com/AUTOMATIC1111/stable-diffusion-webui-aesthetic-gradients) - [Latent blending](https://github.com/lunarring/latentblending/) +- [Face swap](https://github.com/kex0/batch-face-swap) +- [Animator extension](https://github.com/Animator-Anon/animator_extension) - [LORA](https://github.com/cloneofsimo/lora) - - diff --git a/cache.json b/cache.json index 20da5c47a..30b464497 100644 --- a/cache.json +++ b/cache.json @@ -3,6 +3,14 @@ "checkpoint/sd-v15-runwayml.ckpt": { "mtime": 1669915088.0, "sha256": "cc6cb27103417325ff94f52b7a5d2dde45a7515b25c255d8e396c90014281516" + }, + "checkpoint/mood-semireal-v01.ckpt": { + "mtime": 1673181746.8419957, + "sha256": "6a515ebcff5dcdb212830d6ac331752944decdfb4419e2ea6f1f33e6f44c5e58" + }, + "checkpoint/mood-beautyreal-v01.ckpt": { + "mtime": 1673181702.6446307, + "sha256": "bcc0afd3b264ea028928187f56f70840f8d87ccf283b020982beba35d9c7e4ef" } } } \ No newline at end of file diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..159013851 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,66 @@ +# Scripts using Stable-Diffusion/Automatic API + +*Note*: Start **SD/Automatic** using `python launch.py --api` +## Generate + +Text-to-image with all of the possible parameters +Supports upsampling, face restoration and grid creation +> python generate.py --help + +By default uses parameters from `generate.json` + +Parameters that are not specified will be randomized to some extent: +- Prompt will be dynamically created from template of random samples: `random.json` +- Sampler/Scheduler will be randomly picked from available ones +- CFG Scale set to 5-10 + +## Train + +End-to-end embedding training +> python train.py --help + +Combined pipeline: +1. Creates embedding +2. Extracts images if input is movie +3. Preprocesses images +4. Runs training + +## Interrogate + +Runs CLiP and Booru image interrogation on any provided parameters +*(image, list of images, wildcards, folder, etc.)* +> python interrogate.py + +## Promptist + +Attempts to beautify the provided prompt +> python promptist.py + +## Ideas + +Generate complex prompt ideas +> python ideas.py --help + +## SDAPI + +Utility module that handles async communication to Automatic API endpoints +Can be used to manually execute specific commands: +> python sdapi.py progress +> python sdapi.py interrupt + +## FFMPEG + +Utility module that handles video files +Can be used to manually execute specific commands: +> ffmpeg extract --help +> python ffmpeg.py extract --input ~/downloads/vlado.mp4 --output ./vlado --fps 2 --skipstart 3 --skipend 1 + +## Grid + +Utility module to create image grids +> python grid.py --help + +## Bench + +Benchmark your Automatic +> python bench.py diff --git a/cli/bench.py b/cli/bench.py new file mode 100755 index 000000000..aa59326bf --- /dev/null +++ b/cli/bench.py @@ -0,0 +1,112 @@ +#!/bin/env python +""" +sd api txt2img benchmark +""" +import time +import json +import asyncio +import base64 +import io +import sdapi +from util import Map, log +from PIL import Image + +options = Map({ + 'restore_faces': False, + 'prompt': 'photo of two dice on a table', + 'negative_prompt': 'foggy, blurry', + 'steps': 20, + 'batch_size': 1, + 'n_iter': 1, + 'seed': -1, + 'sampler_name': 'Euler a', + 'cfg_scale': 0, + 'width': 512, + 'height': 512 +}) + +# batch = [1, 1, 2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128] +batch = [1, 1, 2, 4, 8, 12, 16] +oom = 0 + +async def txt2img(): + t0 = time.perf_counter() + data = {} + try: + data = await sdapi.post('/sdapi/v1/txt2img', options) + except: + return -1 + if 'error' in data: + return -1 + info = Map(json.loads(data['info'])) + log.debug({ 'info': info }) + for i in range(len(data['images'])): + data['images'][i] = Image.open(io.BytesIO(base64.b64decode(data['images'][i].split(',',1)[0]))) + log.debug({ 'image': data['images'][i].size }) + t1 = time.perf_counter() + return t1 - t0 + +def memstats(): + mem = sdapi.getsync('/sdapi/v1/memory') + cpu = mem.get('ram', 'unavailable') + gpu = mem.get('cuda', 'unavailable') + if 'active' in gpu: + gpu['session'] = gpu.pop('active') + if 'reserved' in gpu: + gpu.pop('allocated') + gpu.pop('reserved') + gpu.pop('inactive') + if 'events' in gpu: + global oom # pylint: disable=global-statement + oom = gpu['events']['oom'] + gpu.pop('events') + return cpu, gpu + +def gb(val: float): + return round(val / 1024 / 1024 / 1024, 2) + +async def main(): + log.info({ 'benchmark': { 'batch-sizes': batch } }) + sdapi.quiet = True + await sdapi.session() + await sdapi.interrupt() + opts = await sdapi.get('/sdapi/v1/options') + opts = Map(opts) + log.info({ 'options': { + 'resolution': [options.width, options.height], + 'model': opts.sd_model_checkpoint, + 'vae': opts.sd_vae, + 'hypernetwork': opts.sd_hypernetwork, + 'sampler': options.sampler_name, + 'clip-stop': opts.CLIP_stop_at_last_layers, + 'preview': opts.show_progress_every_n_steps + } }) + cpu, gpu = memstats() + log.info({ 'system': { 'cpu': cpu, 'gpu': gpu }}) + for i in range(len(batch)): + if oom > 0: + continue + options['batch_size'] = batch[i] + ts = await txt2img() + if ts > 0: + await asyncio.sleep(0) + cpu, gpu = memstats() + if i == 0: + log.info({ 'warmup': round(ts, 2) }) + else: + peak = gpu['session']['peak'] if 'session' in gpu else 0 + log.info({ 'batch': batch[i], 'its': round(options.steps / (ts / batch[i]), 2), 'img': round(ts / batch[i], 2), 'wall': round(ts, 2), 'peak': gb(peak), 'oom': oom > 0 }) + else: + await asyncio.sleep(10) + cpu, gpu = memstats() + log.info({ 'batch': batch[i], 'result': 'error', 'gpu': gpu, 'oom': oom > 0 }) + if oom > 0: + log.info({ 'benchmark': 'ended with oom so you should probably restart your automatic server now' }) + await sdapi.close() + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + log.warning({ 'interrupted': 'keyboard request' }) + sdapi.interruptsync() diff --git a/cli/detect.py b/cli/detect.py new file mode 100755 index 000000000..16a08547f --- /dev/null +++ b/cli/detect.py @@ -0,0 +1,42 @@ +#!/bin/env python +""" +Detect model type + +Works for v1 and v2-base (EPS models), both standard inference and inpainting + +But looking at model dumps between EPS and V type models, its only about parametrization, there are no differences in actual model (its just weighted differently without any structural difference) +So i don't see easy way to auto-detect if model should be run in `EPS` or `V` mode +Only difference are some calculations in `ldm/models/diffusion/ddpm.py` and by then we already need to know which code-path to trigger +(maaaybe there could be a way by looking at some cherry-picked base tensors min/max range, but I dont see that as reliable) +""" + +import os +import sys +import torch + +def signature(model): + if model is None: + return None + try: + size = model['state_dict']['model.diffusion_model.input_blocks.1.1.transformer_blocks.0.attn2.to_k.weight'].shape[1] + unet = model['state_dict']['model.diffusion_model.input_blocks.0.0.weight'].shape[1] + except: + return 'unknown' + guess = 'v1' if size == 768 else 'v2' # 768 for v1 and 1024 for v2 + guess += '-inference' if unet == 4 else '-inpainting' # inference models have shorter inputs, 4 for inference 9 for inpainting + return guess + +def load(file: str): + try: + model = torch.load(file, map_location='cpu') + return model + except Exception as err: + print(f"Error loading {f}: {err}") + +if __name__ == "__main__": + sys.argv.pop(0) + for f in sys.argv: + if os.path.isfile(f): + print(f"Model {f} is of type {signature(load(f))}") + else: + print(f"{f} is not a file") diff --git a/cli/ffmpeg.py b/cli/ffmpeg.py new file mode 100755 index 000000000..6f4418376 --- /dev/null +++ b/cli/ffmpeg.py @@ -0,0 +1,71 @@ +#!/bin/env python +""" +use ffmpeg for animation processing +""" +import os +import json +import subprocess +import pathlib +import argparse +import filetype +from util import log, Map + + +def probe(src: str): + cmd = f"ffprobe -hide_banner -loglevel 0 -print_format json -show_format -show_streams {src}" + result = subprocess.run(cmd, shell = True, capture_output = True, text = True, check = True) + data = json.loads(result.stdout) + i = [x for x in data['streams'] if x["codec_type"] == "video"][0] + video = Map({ + 'codec': i['codec_name']+'/'+i['codec_tag_string'], + 'resolution': [int(i['width']), int(i['height'])], + 'duration': float(i['duration']), + 'frames': int(i['nb_frames']), + 'bitrate': round(float(i['bit_rate']) / 1024), + }) + return video + + +def extract(src: str, dst: str, rate: float = 0.015, fps: float = 0, start = 0, end = 0): + images = [] + if not os.path.isfile(src) or not filetype.is_video(src): + log.error({ 'extract': 'input is not movie file' }) + return + dst = dst if dst.endswith('/') else dst + '/' + + video = probe(src) + log.info({ 'extract': { 'source': src, **video } }) + + ssstart = f' -ss {start}' if start > 0 else '' + ssend = f' -to {video.duration - end}' if start > 0 else '' + filename = pathlib.Path(src).stem + if rate > 0: + cmd = f"ffmpeg -hide_banner -y -loglevel info {ssstart} {ssend} -i {src} -filter:v \"select='gt(scene,{rate})',metadata=print\" -vsync vfr -frame_pts 1 {dst}{filename}-%05d.jpg" + elif fps > 0: + cmd = f"ffmpeg -hide_banner -y -loglevel info {ssstart} {ssend} -i {src} -r {fps} -vsync vfr -frame_pts 1 {dst}{filename}-%05d.jpg" + else: + log.error({ 'extract': 'requires either rate or fps' }) + return 0 + log.debug({ 'extract': cmd }) + pathlib.Path(dst).mkdir(parents = True, exist_ok = True) + result = subprocess.run(cmd, shell = True, capture_output = True, text = True, check = True) + for line in result.stderr.split('\n'): + if 'pts_time' in line: + log.debug({ 'extract': { 'keyframe': line.strip().split(' ')[-1].split(':')[-1] } }) + images = next(os.walk(dst))[2] + log.info({ 'extract': { 'destination': dst, 'keyframes': len(images), 'rate': rate, 'fps': fps } }) + return len(images) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="ffmpeg pipeline") + parser.add_argument("command", choices = ["extract", "animate"]) + parser.add_argument("--input", type = str, required = True, help="input") + parser.add_argument("--output", type = str, required = True, help="output") + parser.add_argument("--rate", type = float, default = 0, required = False, help="extraction change rate threshold") + parser.add_argument("--fps", type = float, default = 0, required = False, help="extraction frames per second") + parser.add_argument("--skipstart", type = float, default = 1, required = False, help="skip time from start of video") + parser.add_argument("--skipend", type = float, default = 1, required = False, help="skip time to end of video") + params = parser.parse_args() + if params.command == "extract": + extract(src = params.input, dst = params.output, rate = params.rate, fps = params.fps, start = params.skipstart, end = params.skipend) diff --git a/cli/generate.json b/cli/generate.json new file mode 100644 index 000000000..84756322e --- /dev/null +++ b/cli/generate.json @@ -0,0 +1,38 @@ +{ + "paths": + { + "root": "/mnt/c/Users/mandi/OneDrive/Generative/Generate", + "generate": "image", + "upscale": "upscale", + "grid": "grid" + }, + "generate": + { + "restore_faces": false, + "prompt": "dynamic", + "negative_prompt": "foggy, blurry, blurred, duplicate, ugly, mutilated, mutation, mutated, out of frame, bad anatomy, disfigured, deformed, censored, low res, watermark, text, poorly drawn face, signature", + "steps": 20, + "batch_size": 1, + "n_iter": 1, + "seed": -1, + "sampler_name": "random", + "cfg_scale": 0, + "width": 512, + "height": 512 + }, + "upscale": + { + "upscaler_1": "SwinIR_4x", + "upscaler_2": "None", + "upscale_first": false, + "upscaling_resize": 0, + "gfpgan_visibility": 0, + "codeformer_visibility": 0, + "codeformer_weight": 0.5 + }, + "options": + { + "sd_model_checkpoint": "mix-berrymix", + "sd_vae": "vae-ft-mse-840000-ema-pruned" + } +} diff --git a/cli/generate.py b/cli/generate.py new file mode 100755 index 000000000..fb5b24817 --- /dev/null +++ b/cli/generate.py @@ -0,0 +1,355 @@ +#!/bin/env python +# pylint: disable=no-member +""" +generate batches of images from prompts and upscale them + +params: run with `--help` + +default workflow runs infinite loop and prints stats when interrupted: +1. choose random scheduler lookup all available and pick one +2. generate dynamic prompt based on styles, embeddings, places, artists, suffixes +3. beautify prompt +4. generate 3x3 images +5. create image grid +6. upscale images with face restoration +""" + +import argparse +import asyncio +import base64 +import io +import json +import logging +import math +import os +import pathlib +import secrets +import time +import sys +from random import randrange + +from PIL import Image +from PIL.ExifTags import TAGS +from PIL.TiffImagePlugin import ImageFileDirectory_v2 +from sdapi import close, get, interrupt, post, session +from util import Map, log + +sd = {} +random = {} +stats = Map({ "images": 0, "wall": 0, "generate": 0, "upscale": 0 }) +avg = {} + + +def grid(data): + if len(data.image) > 1: + w, h = data.image[0].size + rows = round(math.sqrt(len(data.image))) + cols = math.ceil(len(data.image) / rows) + image = Image.new('RGB', size = (cols * w, rows * h), color = 'black') + for i, img in enumerate(data.image): + image.paste(img, box=(i % cols * w, i // cols * h)) + short = data.info.prompt[:min(len(data.info.prompt), 96)] # limit prompt part of filename to 96 chars + name = "{seed:0>9}-{short}.jpg".format(short = short, seed = data.info.all_seeds[0]) # pylint: disable=consider-using-f-string + f = os.path.join(sd.paths.root, sd.paths.grid, name) + log.info({ "grid": { "name": f, "size": image.size, "images": len(data.image) } }) + image.save(f, "JPEG", exif = exif(data.info, None, "grid"), optimize = True, quality = 70) + return image + + +def exif(info, i = None, op = "generate"): + seed = [info.all_seeds[i]] if len(info.all_seeds) > 0 and i is not None else info.all_seeds # always returns list + seed = ', '.join([str(x) for x in seed]) # int list to str list to single str + template = "{prompt} | negative {negative_prompt} | seed {s} | steps {steps} | cfgscale {cfg_scale} | sampler {sampler_name} | batch {batch_size} | timestamp {job_timestamp} | model {model} | vae {vae}".format(s = seed, model = sd.options["sd_model_checkpoint"], vae = sd.options["sd_vae"], **info) # pylint: disable=consider-using-f-string + if op == "upscale": + template += ' | faces gfpgan' if sd.upscale.gfpgan_visibility > 0 else '' + template += ' | faces codeformer' if sd.upscale.codeformer_visibility > 0 else '' + template += ' | upscale {resize}x {upscaler}'.format(resize = sd.upscale.upscaling_resize, upscaler = sd.upscale.upscaler_1) if sd.upscale.upscaler_1 != "None" else '' # pylint: disable=consider-using-f-string + template += ' | upscale {resize}x {upscaler}'.format(resize = sd.upscale.upscaling_resize, upscaler = sd.upscale.upscaler_2) if sd.upscale.upscaler_2 != "None" else '' # pylint: disable=consider-using-f-string + if op == "grid": + template += ' | grid {num}'.format(num = sd.generate.batch_size * sd.generate.n_iter) # pylint: disable=consider-using-f-string + ifd = ImageFileDirectory_v2() + exif_stream = io.BytesIO() + _TAGS = dict(((v, k) for k, v in TAGS.items())) # enumerate possible exif tags + ifd[_TAGS["ImageDescription"]] = template + ifd.save(exif_stream) + val = b"Exif\x00\x00" + exif_stream.getvalue() + return val + + +def prompt(params): # generate dynamic prompt or use one if provided + sd.generate.prompt = params.prompt if params.prompt != "dynamic" else secrets.choice(random.prompts) + embedding = params.embedding if params.embedding != 'random' else secrets.choice(random.embeddings) + sd.generate.prompt = sd.generate.prompt.replace('', embedding) + artist = params.artist if params.artist != 'random' else secrets.choice(random.artists) + sd.generate.prompt = sd.generate.prompt.replace('', artist) + style = params.style if params.style != 'random' else secrets.choice(random.styles) + sd.generate.prompt = sd.generate.prompt.replace('