diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a16514d5..c5227cad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,16 @@ # Change Log for SD.Next -## Update for 2024-10-07 +## Update for 2024-10-08 -### Highlights for 2024-10-07 +### Highlights for 2024-10-08 -- **Reprocess**: New workflow options that allow you to generate at lower quality and then reprocess at higher quality for select images only, or generate without hires/refine and then reprocess with hires/refine +- **Reprocess**: New workflow options that allow you to generate at lower quality and then + reprocess at higher quality for select images only or generate without hires/refine and then reprocess with hires/refine + and you can pick any previous latent from auto-captured history! - **Detailer** Fully built-in detailer workflow without with support for all standard models - New fine-tuned [CLiP-ViT-L]((https://huggingface.co/zer0int/CLIP-GmP-ViT-L-14)) 1st stage **text-encoders** used by SD15, SDXL, Flux.1, etc. brings additional details to your images -- Integration with [Ctrl+X](https://github.com/genforce/ctrl-x) which allows for control of **structure and appearance** without the need for extra models and [APG: Adaptive Projected Guidance](https://arxiv.org/pdf/2410.02416) for optimal **guidance** control +- Integration with [Ctrl+X](https://github.com/genforce/ctrl-x) which allows for control of **structure and appearance** without the need for extra models and + [APG: Adaptive Projected Guidance](https://arxiv.org/pdf/2410.02416) for optimal **guidance** control - Auto-detection of best available **device/dtype** settings for your platform and GPU reduces neeed for manual configuration - Full rewrite of **sampler options**, not far more streamlined with tons of new options to tweak scheduler behavior - Improved **LoRA** detection and handling for all supported models @@ -17,11 +20,21 @@ And other goodies like multiple *XYZ grid* improvements, additional *Flux Contro ### Details for 2024-10-07 - **reprocess** - - new top-level button: reprocess your last generated image(s) + - new top-level button: reprocess latent from your history of generated image(s) - generate using full-quality:off and then reprocess using *full quality decode* - generate without hires/refine and then *reprocess with hires/refine* *note*: you can change hires/refine settings and run-reprocess again! - - reprocess using *face-restore* + - reprocess using *detailer* + +- **history** + - by default, **reprocess** will pick last latent, but you can select any latent from history! + - history is under *networks -> history* + each history item includes info on operations that were used, timestamp and metadata + - any latent operation during workflow automatically adds one or more items to history + e.g. generate base + upscale + hires + detailer + - history size: *settings -> execution -> latent history size* + memory usage is ~130kb of ram for 1mp image + - *note* list of latents in history is not auto-refreshed, use refresh button - **text encoder**: - allow loading different custom text encoders: *clip-vit-l, clip-vit-g, t5* diff --git a/javascript/extraNetworks.js b/javascript/extraNetworks.js index ab818a8d1..032ee4999 100644 --- a/javascript/extraNetworks.js +++ b/javascript/extraNetworks.js @@ -328,6 +328,13 @@ function quickSaveStyle() { } } +function selectHistory(id) { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const init = { method: 'POST', body: { name: id }, headers }; + fetch('/sdapi/v1/history', { method: 'POST', body: JSON.stringify({ name: id }), headers }); +} + let enDirty = false; function closeDetailsEN(...args) { // log('closeDetailsEN'); diff --git a/modules/api/api.py b/modules/api/api.py index 4b7c750fd..23c0a77f1 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -84,6 +84,8 @@ class Api: self.add_api_route("/sdapi/v1/unload-checkpoint", endpoints.post_unload_checkpoint, methods=["POST"]) self.add_api_route("/sdapi/v1/reload-checkpoint", endpoints.post_reload_checkpoint, methods=["POST"]) self.add_api_route("/sdapi/v1/refresh-vae", endpoints.post_refresh_vae, methods=["POST"]) + self.add_api_route("/sdapi/v1/history", endpoints.get_history, methods=["GET"], response_model=List[str]) + self.add_api_route("/sdapi/v1/history", endpoints.post_history, methods=["POST"], response_model=int) # gallery api gallery.register_api(app) diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py index 08b54c9df..61993db84 100644 --- a/modules/api/endpoints.py +++ b/modules/api/endpoints.py @@ -158,3 +158,10 @@ def post_pnginfo(req: models.ReqImageInfo): params = infotext.parse(geninfo) script_callbacks.infotext_pasted_callback(geninfo, params) return models.ResImageInfo(info=geninfo, items=items, parameters=params) + +def get_history(): + return shared.history.list + +def post_history(req: models.ReqHistory): + shared.history.index = shared.history.find(req.name) + return shared.history.index diff --git a/modules/api/models.py b/modules/api/models.py index cd67522d1..3cf3aade9 100644 --- a/modules/api/models.py +++ b/modules/api/models.py @@ -318,6 +318,9 @@ class ReqVQA(BaseModel): model: str = Field(default="MS Florence 2 Base", title="Model", description="The interrogate model used.") question: str = Field(default="describe the image", title="Question", description="Question to ask the model.") +class ReqHistory(BaseModel): + name: str = Field(title="Name", description="Name of the history item to select") + class ResVQA(BaseModel): answer: Optional[str] = Field(default=None, title="Answer", description="The generated answer for the image.") diff --git a/modules/history.py b/modules/history.py index f98a0026d..ea82fedb9 100644 --- a/modules/history.py +++ b/modules/history.py @@ -1,3 +1,10 @@ +""" +TODO: +- apply metadata +- preview +- load/save +""" + import sys import datetime from collections import deque @@ -5,17 +12,20 @@ from modules import shared, devices class Item(): - def __init__(self, latent, preview=None, meta=None): - self.ts = datetime.datetime.now() + def __init__(self, latent, preview=None, info=None, ops=[]): + self.ts = datetime.datetime.now().replace(microsecond=0) + self.name = self.ts.strftime('%Y-%m-%d %H:%M:%S') self.latent = latent.detach().clone().to(devices.cpu) self.preview = preview - self.meta = meta + self.info = info + self.ops = ops.copy() + self.size = sys.getsizeof(self.latent.storage()) class History(): def __init__(self): - self.latents = deque(maxlen=1000) - shared.log.debug(f'History init: max={shared.opts.latent_history}') + self.index = -1 + self.latents = deque(maxlen=1024) @property def count(self): @@ -25,32 +35,48 @@ class History(): def size(self): s = 0 for item in self.latents: - s += sys.getsizeof(item.latent.storage()) + s += item.size return s @property def list(self): - return [item.ts for item in self.latents] + shared.log.info(f'History: items={self.count}/{shared.opts.latent_history} size={self.size}') + return [item.name for item in self.latents] @property def latest(self): return self.get(0) - def add(self, latent, preview=None, meta=None): - item = Item(latent, preview, meta) + @property + def selected(self): + if self.index >= 0 and self.index < self.count: + index = self.index + latent = self.get(self.index) + self.index = -1 + return latent, index + return self.latest, -1 + + def find(self, name): + for i, item in enumerate(self.latents): + if item.name == name: + return i + return -1 + + def add(self, latent, preview=None, info=None, ops=[]): + item = Item(latent, preview, info, ops) self.latents.appendleft(item) - shared.log.debug(f'History add: shape={latent.shape} dtype={latent.dtype} count={self.count}') + # shared.log.debug(f'History add: shape={latent.shape} dtype={latent.dtype} count={self.count}') if self.count >= shared.opts.latent_history: self.latents.pop() def get(self, index: int = 0): item = self.latents[index] - shared.log.debug(f'History get: index={index} time={item.ts} shape={item.latent.shape} dtype={item.latent.dtype} count={self.count}') + # shared.log.debug(f'History get: index={index} time={item.ts} shape={item.latent.shape} dtype={item.latent.dtype} count={self.count}') return item.latent.to(devices.device) def clear(self): self.latents.clear() - shared.log.debug(f'History clear: count={self.count}') + # shared.log.debug(f'History clear: count={self.count}') def load(self): pass diff --git a/modules/processing_args.py b/modules/processing_args.py index 2d6fa58d3..0e2999128 100644 --- a/modules/processing_args.py +++ b/modules/processing_args.py @@ -49,7 +49,10 @@ def task_specific_kwargs(p, model): elif (sd_models.get_diffusers_task(model) == sd_models.DiffusersTaskType.INPAINTING or is_img2img_model) and len(getattr(p, 'init_images', [])) > 0: if shared.sd_model_type == 'sdxl': model.register_to_config(requires_aesthetics_score = False) - p.ops.append('inpaint') + if p.detailer: + p.ops.append('detailer') + else: + p.ops.append('inpaint') width, height = processing_helpers.resize_init_images(p) task_args = { 'image': p.init_images, diff --git a/modules/processing_diffusers.py b/modules/processing_diffusers.py index 8d19dfc4d..0236d6d50 100644 --- a/modules/processing_diffusers.py +++ b/modules/processing_diffusers.py @@ -95,6 +95,7 @@ def process_base(p: processing.StableDiffusionProcessing): output = shared.sd_model(**base_args) if isinstance(output, dict): output = SimpleNamespace(**output) + shared.history.add(output.images, info=processing.create_infotext(p), ops=p.ops) timer.process.record('pipeline') hidiffusion.unapply() sd_models_compile.openvino_post_compile(op="base") # only executes on compiled vino models @@ -209,6 +210,7 @@ def process_hires(p: processing.StableDiffusionProcessing, output): output = shared.sd_model(**hires_args) # pylint: disable=not-callable if isinstance(output, dict): output = SimpleNamespace(**output) + shared.history.add(output.images, info=processing.create_infotext(p), ops=p.ops) sd_models_compile.check_deepcache(enable=False) sd_models_compile.openvino_post_compile(op="base") except AssertionError as e: @@ -281,6 +283,7 @@ def process_refine(p: processing.StableDiffusionProcessing, output): output = shared.sd_refiner(**refiner_args) # pylint: disable=not-callable if isinstance(output, dict): output = SimpleNamespace(**output) + shared.history.add(output.images, info=processing.create_infotext(p), ops=p.ops) sd_models_compile.openvino_post_compile(op="refiner") except AssertionError as e: shared.log.info(e) @@ -322,7 +325,6 @@ def process_decode(p: processing.StableDiffusionProcessing, output): full_quality = p.full_quality, width = width, height = height, - save = p.state == '', ) elif hasattr(output, 'images'): results = output.images @@ -388,7 +390,7 @@ def process_diffusers(p: processing.StableDiffusionProcessing): if 'base' not in p.skip: output = process_base(p) else: - output = SimpleNamespace(images=shared.history.latest) + output, _index = SimpleNamespace(images=shared.history.selected) if shared.state.interrupted or shared.state.skipped: shared.sd_model = orig_pipeline diff --git a/modules/processing_vae.py b/modules/processing_vae.py index 454eeed32..75347f416 100644 --- a/modules/processing_vae.py +++ b/modules/processing_vae.py @@ -145,7 +145,7 @@ def taesd_vae_encode(image): return encoded -def vae_decode(latents, model, output_type='np', full_quality=True, width=None, height=None, save=True): +def vae_decode(latents, model, output_type='np', full_quality=True, width=None, height=None): t0 = time.time() if latents is None or not torch.is_tensor(latents): # already decoded return latents @@ -166,8 +166,6 @@ def vae_decode(latents, model, output_type='np', full_quality=True, width=None, latents = latents.unsqueeze(0) if latents.shape[0] == 4 and latents.shape[1] != 4: # likely animatediff latent latents = latents.permute(1, 0, 2, 3) - if save: - shared.history.add(latents) if latents.shape[-1] <= 4: # not a latent, likely an image decoded = latents.float().cpu().numpy() @@ -213,7 +211,7 @@ def vae_encode(image, model, full_quality=True): # pylint: disable=unused-variab def reprocess(gallery): from PIL import Image from modules import images - latent = shared.history.latest + latent, index = shared.history.selected if latent is None or gallery is None: return None shared.log.info(f'Reprocessing: latent={latent.shape}') @@ -231,6 +229,7 @@ def reprocess(gallery): if shared.opts.samples_save: images.save_image(i1, info=info, forced_filename=fn) i1.already_saved_as = fn - outputs.append(i0) + if index == -1: + outputs.append(i0) outputs.append(i1) return outputs diff --git a/modules/shared.py b/modules/shared.py index ccfca97cd..81befa466 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -423,9 +423,9 @@ options_templates.update(options_section(('sd', "Execution & Models"), { "prompt_mean_norm": OptionInfo(False, "Prompt attention normalization", gr.Checkbox), "comma_padding_backtrack": OptionInfo(20, "Prompt padding", gr.Slider, {"minimum": 0, "maximum": 74, "step": 1, "visible": not native }), "prompt_attention": OptionInfo("Full parser", "Prompt attention parser", gr.Radio, {"choices": ["Full parser", "Compel parser", "xhinker parser", "A1111 parser", "Fixed attention"] }), + "latent_history": OptionInfo(16, "Latent history size", gr.Slider, {"minimum": 1, "maximum": 100, "step": 1}), "sd_checkpoint_cache": OptionInfo(0, "Cached models", gr.Slider, {"minimum": 0, "maximum": 10, "step": 1, "visible": not native }), "sd_vae_checkpoint_cache": OptionInfo(0, "Cached VAEs", gr.Slider, {"minimum": 0, "maximum": 10, "step": 1, "visible": False}), - "latent_history": OptionInfo(1, "Latent history size", gr.Slider, {"minimum": 1, "maximum": 100, "step": 1}), "sd_disable_ckpt": OptionInfo(False, "Disallow models in ckpt format", gr.Checkbox, {"visible": False}), "diffusers_version": OptionInfo("", "Diffusers version", gr.Textbox, {"visible": False}), })) diff --git a/modules/ui_extra_networks.py b/modules/ui_extra_networks.py index e8feb2501..58fb463a9 100644 --- a/modules/ui_extra_networks.py +++ b/modules/ui_extra_networks.py @@ -82,7 +82,7 @@ def init_api(app): item = next(iter([x for x in page.items if x['name'] == item]), None) if item is None: return JSONResponse({ 'info': 'none' }) - info = page.find_info(item['filename']) + info = page.find_info(item.get('filename', None) or item.get('name', None)) if info is None: info = {} # shared.log.debug(f"Networks info: page='{page.name}' item={item['name']} len={len(info)}") @@ -95,7 +95,7 @@ def init_api(app): item = next(iter([x for x in page.items if x['name'] == item]), None) if item is None: return JSONResponse({ 'description': 'none' }) - desc = page.find_description(item['filename']) + desc = page.find_description(item.get('filename', None) or item.get('name', None)) if desc is None: desc = '' # shared.log.debug(f"Networks desc: page='{page.name}' item={item['name']} len={len(desc)}") @@ -295,11 +295,11 @@ class ExtraNetworksPage: args = { "tabname": tabname, "page": self.name, - "name": item["name"], + "name": item.get('name', ''), "title": os.path.basename(item["name"].replace('_', ' ')), - "filename": item["filename"], - "tags": '|'.join([item.get("tags")] if isinstance(item.get("tags", {}), str) else list(item.get("tags", {}).keys())), - "preview": html.escape(item.get("preview", self.link_preview('html/card-no-preview.png'))), + "filename": item.get('filename', ''), + "tags": '|'.join([item.get('tags')] if isinstance(item.get('tags', {}), str) else list(item.get('tags', {}).keys())), + "preview": html.escape(item.get('preview', None) or self.link_preview('html/card-no-preview.png')), "width": shared.opts.extra_networks_card_size, "height": shared.opts.extra_networks_card_size if shared.opts.extra_networks_card_square else 'auto', "fit": shared.opts.extra_networks_card_fit, @@ -318,6 +318,8 @@ class ExtraNetworksPage: return self.card.format(**args) except Exception as e: shared.log.error(f'Extra networks item error: page={tabname} item={item["name"]} {e}') + if os.environ.get('SD_EN_DEBUG', None) is not None: + errors.display(e, 'Extra networks') return "" def find_preview_file(self, path): @@ -391,17 +393,18 @@ class ExtraNetworksPage: if tag == 'p': self.text += '\n' - fn = os.path.splitext(path)[0] + '.txt' - if os.path.exists(fn): - try: - with open(fn, "r", encoding="utf-8", errors="replace") as f: - txt = f.read() - txt = re.sub('[<>]', '', txt) - return txt - except OSError: - pass - if info is None: - info = self.find_info(path) + if path is not None: + fn = os.path.splitext(path)[0] + '.txt' + if os.path.exists(fn): + try: + with open(fn, "r", encoding="utf-8", errors="replace") as f: + txt = f.read() + txt = re.sub('[<>]', '', txt) + return txt + except OSError: + pass + if info is None: + info = self.find_info(path) desc = info.get('description', '') or '' f = HTMLFilter() f.feed(desc) @@ -413,14 +416,15 @@ class ExtraNetworksPage: data = {} if shared.cmd_opts.no_metadata: return data - fn = os.path.splitext(path)[0] + '.json' - if os.path.exists(fn): - t0 = time.time() - data = shared.readfile(fn, silent=True) - if type(data) is list: - data = data[0] - t1 = time.time() - self.info_time += t1-t0 + if path is not None: + fn = os.path.splitext(path)[0] + '.json' + if os.path.exists(fn): + t0 = time.time() + data = shared.readfile(fn, silent=True) + if type(data) is list: + data = data[0] + t1 = time.time() + self.info_time += t1-t0 return data @@ -743,7 +747,8 @@ def create_ui(container, button_parent, tabname, skip_indexing = False): def show_details(text, img, desc, info, meta, description, prompt, negative, parameters, wildcards, params, _dummy1=None, _dummy2=None): page, item = get_item(state, params) - if item is not None and hasattr(item, 'name'): + valid = item is not None and hasattr(item, 'name') and hasattr(item, 'filename') + if valid: stat = os.stat(item.filename) if os.path.exists(item.filename) else None desc = item.description fullinfo = shared.readfile(os.path.splitext(item.filename)[0] + '.json', silent=True) @@ -844,7 +849,7 @@ def create_ui(container, button_parent, tabname, skip_indexing = False): negative, # gr.textbox parameters, # gr.textbox wildcards, # gr.textbox - gr.update(visible=item is not None), # details ui visible + gr.update(visible=valid), # details ui visible gr.update(visible=page is not None and page.title != 'Style'), # details ui tabs visible gr.update(visible=page is not None and page.title == 'Style'), # details ui text visible ] @@ -852,6 +857,9 @@ def create_ui(container, button_parent, tabname, skip_indexing = False): def ui_refresh_click(title): pages = [] for page in get_pages(): + if page.title != title: + pages.append(page.html) + continue page.page_time = 0 page.refresh_time = 0 page.refresh() diff --git a/modules/ui_extra_networks_history.py b/modules/ui_extra_networks_history.py index 7b471e394..72f31731a 100644 --- a/modules/ui_extra_networks_history.py +++ b/modules/ui_extra_networks_history.py @@ -1,11 +1,13 @@ import time +import json +import html from modules import shared, ui_extra_networks class ExtraNetworksPageHistory(ui_extra_networks.ExtraNetworksPage): def __init__(self): - super().__init__('History') # shared.log.trace('History init') + super().__init__('History') self.last_refresh = 0 def refresh(self): @@ -17,13 +19,21 @@ class ExtraNetworksPageHistory(ui_extra_networks.ExtraNetworksPage): def list_items(self): # shared.log.trace('History list') - return shared.history.list + for item in shared.history.latents: + title = ', '.join(list(set(item.ops))) + '
' + item.name + yield { + "type": 'History', + "name": title, + "preview": item.preview, + "mtime": item.ts, + "size": item.size, + # "info": item.info, + # "description": item.info, + "onclick": '"' + html.escape(f"""return selectHistory({json.dumps(item.name)})""") + '"', + } - def create_page(self, tabname, skip = False): - # shared.log.trace(f'History page: tab={tabname} skip={skip}') - self.page_time = time.time() - if tabname == 'txt2img': - self.last_refresh = time.time() - if self.page_time <= self.last_refresh: # cached page - self.refresh() - return self.patch(self.html, tabname) + def find_description(self, path, info=None): + name = path.split('
')[-1] + items = [l for l in shared.history.latents if l.name == name] + if len(items) > 0: + return items[0].info diff --git a/wiki b/wiki index c648e82c3..fa80fa007 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit c648e82c3b26813bff59dad567438e877fff02a1 +Subproject commit fa80fa0071aea16285359b1ebfacd21b385efad4