Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display improvements to PNG Info tab #16415

Open
wants to merge 20 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 90 additions & 15 deletions modules/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,113 @@
import re
import shutil
import json

import html

import torch
import tqdm

from modules import shared, images, sd_models, sd_vae, sd_models_config, errors
from modules import shared, images, sd_models, sd_vae, sd_models_config, errors, infotext_utils
from modules.ui_common import plaintext_to_html
import gradio as gr
import safetensors.torch


def run_pnginfo(image):
if image is None:
return '', '', ''
def pnginfo_format_string(plain_text):
content = "<br>\n".join(html.escape(x) for x in str(plain_text).split('\n'))
return content

geninfo, items = images.read_info_from_image(image)

def pnginfo_format_setting(name, value):
cls_name = 'geninfo-setting-string' if value.startswith('"') else 'geninfo-setting-value'
return f"<span class='geninfo-setting-name'>{html.escape(name)}:</span> <span class='{cls_name}' onclick='uiCopyElementText(this)'>{html.escape(value)}</span>"


def pnginfo_format_quicklink(name):
return f"<span class='geninfo-quick-link' onclick='uiCopyPngInfo(this, \"{name}\")'>[{html.escape(name)}]</span>"


def pnginfo_html_v1(geninfo, items):
items = {**{'parameters': geninfo}, **items}
info_html = ''
for key, text in items.items():
info_html += f"""<div class="infotext">
<p><b>{plaintext_to_html(str(key))}</b></p>
<p>{plaintext_to_html(str(text))}</p>
</div>""".strip() + "\n"

if len(info_html) == 0:
message = "Nothing found in the image."
info_html = f"<div><p>{message}<p></div>"

return info_html


def pnginfo_html_v2(geninfo, items):

prompt, negative_prompt, last_line = infotext_utils.split_infotext(geninfo)
res = infotext_utils.parameters_to_dict(last_line)
if not any([prompt, res, items]):
return pnginfo_html_v1(geninfo, items)

info_html = ''
if prompt:
info_html += f"""
<div class='pnginfo-page'>
<p><b>parameters</b><br>
{pnginfo_format_quicklink("Copy")}&nbsp;{pnginfo_format_quicklink("Prompt")}"""
if negative_prompt:
info_html += f'&nbsp;{pnginfo_format_quicklink("Negative")}'
info_html += f"""&nbsp;{pnginfo_format_quicklink("Settings")}
</p>
<p id='pnginfo-positive'>{pnginfo_format_string(prompt)}</p>"""
if negative_prompt:
info_html += f"""
<p>
<span class='geninfo-setting-name'>Negative prompt:</span><br><span id='pnginfo-negative'>{pnginfo_format_string(negative_prompt)}</span>
</p>
"""
if res:
info_html += "<p id='pnginfo-settings'>"
first = True
for key, value in res.items():
if first:
first = False
else:
info_html += ", "
info_html += pnginfo_format_setting(key, value)
info_html += "</p>"
info_html += "</div>\n"

info = ''
for key, text in items.items():
info += f"""
info_html += f"""
<div class="infotext">
<p><b>{plaintext_to_html(str(key))}</b></p>
<p>{plaintext_to_html(str(text))}</p>
</div>
""".strip()+"\n"

if len(info) == 0:
return info_html


pnginfo_html_map = {
'Default': pnginfo_html_v2,
'Parsed': pnginfo_html_v2,
'Raw': pnginfo_html_v1,
}


def run_pnginfo(image):
if image is None:
return '', '', ''

geninfo, items = images.read_info_from_image(image)
info_html = pnginfo_html_map.get(shared.opts.png_info_html_style, pnginfo_html_v2)(geninfo, items)

if len(info_html) == 0:
message = "Nothing found in the image."
info = f"<div><p>{message}<p></div>"
info_html = f"<div><p>{message}<p></div>"

return '', geninfo, info
return '', geninfo, info_html


def create_config(ckpt_result, config_source, a, b, c):
Expand Down Expand Up @@ -202,8 +277,8 @@ def filename_nothing():
if a.shape[1] == 4 and b.shape[1] == 8:
raise RuntimeError("When merging instruct-pix2pix model with a normal one, A must be the instruct-pix2pix model.")

if a.shape[1] == 8 and b.shape[1] == 4:#If we have an Instruct-Pix2Pix model...
theta_0[key][:, 0:4, :, :] = theta_func2(a[:, 0:4, :, :], b, multiplier)#Merge only the vectors the models have in common. Otherwise we get an error due to dimension mismatch.
if a.shape[1] == 8 and b.shape[1] == 4: # If we have an Instruct-Pix2Pix model...
theta_0[key][:, 0:4, :, :] = theta_func2(a[:, 0:4, :, :], b, multiplier) # Merge only the vectors the models have in common. Otherwise we get an error due to dimension mismatch.
result_is_instruct_pix2pix_model = True
else:
assert a.shape[1] == 9 and b.shape[1] == 4, f"Bad dimensions for merged layer {key}: A={a.shape}, B={b.shape}"
Expand Down Expand Up @@ -274,7 +349,7 @@ def filename_nothing():

if save_metadata and add_merge_recipe:
merge_recipe = {
"type": "webui", # indicate this model was merged with webui's built-in merger
"type": "webui", # indicate this model was merged with webui's built-in merger
"primary_model_hash": primary_model_info.sha256,
"secondary_model_hash": secondary_model_info.sha256 if secondary_model_info else None,
"tertiary_model_hash": tertiary_model_info.sha256 if tertiary_model_info else None,
Expand Down Expand Up @@ -312,7 +387,7 @@ def add_model_metadata(checkpoint_info):

_, extension = os.path.splitext(output_modelname)
if extension.lower() == ".safetensors":
safetensors.torch.save_file(theta_0, output_modelname, metadata=metadata if len(metadata)>0 else None)
safetensors.torch.save_file(theta_0, output_modelname, metadata=metadata if len(metadata) > 0 else None)
else:
torch.save(theta_0, output_modelname)

Expand Down
91 changes: 60 additions & 31 deletions modules/infotext_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,54 +231,83 @@ def restore_old_hires_fix_params(res):
res['Hires resize-2'] = height


def parse_generation_parameters(x: str, skip_fields: list[str] | None = None):
"""parses generation parameters string, the one you see in text field under the picture in UI:
```
girl with an artist's beret, determined, blue eyes, desert scene, computer monitors, heavy makeup, by Alphonse Mucha and Charlie Bowater, ((eyeshadow)), (coquettish), detailed, intricate
Negative prompt: ugly, fat, obese, chubby, (((deformed))), [blurry], bad anatomy, disfigured, poorly drawn face, mutation, mutated, (extra_limb), (ugly), (poorly drawn hands), messy drawing
Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 965400086, Size: 512x512, Model hash: 45dee52b
```

returns a dict with field values
def split_infotext(x: str):
"""splits infotext into prompt, negative prompt, parameters
every line from the beginning to the first line starting with "Negative prompt:" is treated as prompt
every line after that is treated as negative_prompt
the last_line is only treated as parameters if it has contains at least 3 parameters
"""
if skip_fields is None:
skip_fields = shared.opts.infotext_skip_pasting
if x is None:
return '', '', ''
prompt, negative_prompt, done_with_prompt = '', '', False
*lines, last_line = x.strip().split('\n')

res = {}

prompt = ""
negative_prompt = ""

done_with_prompt = False

*lines, lastline = x.strip().split("\n")
if len(re_param.findall(lastline)) < 3:
lines.append(lastline)
lastline = ''
if len(re_param.findall(last_line)) < 3:
lines.append(last_line)
last_line = ''

for line in lines:
line = line.strip()
if line.startswith("Negative prompt:"):

if not done_with_prompt and line.startswith('Negative prompt:'):
done_with_prompt = True
line = line[16:].strip()
if done_with_prompt:
negative_prompt += ("" if negative_prompt == "" else "\n") + line
negative_prompt += '\n' + line
else:
prompt += ("" if prompt == "" else "\n") + line
prompt += '\n' + line

return prompt.strip(), negative_prompt.strip(), last_line


def parameters_to_dict(parameters: str):
"""convert parameters from string to dict"""
return dict(re_param.findall(parameters))


for k, v in re_param.findall(lastline):
def parse_parameters(param_dict: dict):
res = {}
for k, v in param_dict.items():
try:
if v[0] == '"' and v[-1] == '"':
if v.startswith('"') and v.endswith('"'):
v = unquote(v)

m = re_imagesize.match(v)
if m is not None:
if (m := re_imagesize.match(v)) is not None:
# values matching regex r"^(\d+)x(\d+)$" will be split into two keys
# {size: 512x512} -> {'size-1': '512', 'size-2': '512'}
res[f"{k}-1"] = m.group(1)
res[f"{k}-2"] = m.group(2)
else:
res[k] = v
except Exception:
print(f"Error parsing \"{k}: {v}\"")

except Exception as e:
print(f"Error parsing \"{k}: {v}\" : {e}")
return res


def parse_generation_parameters(x: str, skip_fields: list[str] | None = None):
"""parses generation parameters string, the one you see in text field under the picture in UI:
```
girl with an artist's beret, determined, blue eyes, desert scene, computer monitors, heavy makeup, by Alphonse Mucha and Charlie Bowater, ((eyeshadow)), (coquettish), detailed, intricate
Negative prompt: ugly, fat, obese, chubby, (((deformed))), [blurry], bad anatomy, disfigured, poorly drawn face, mutation, mutated, (extra_limb), (ugly), (poorly drawn hands), messy drawing
Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 965400086, Size: 512x512, Model hash: 45dee52b
```

Returns a dict with field values

Notes: issues with infotext syntax
1. prompt can not contains a startng line with "Negative prompt:" as it will
2. if the last_line contains less than 3 parameters it will be treated as part of the negative_prompt, even though it might actually be parameters

Changes:
1.11.0 : if a user decides to use a literal "Negative prompt:" as a part of their negative prompt at the beginning of the a line after the first line, webui will remove it from the prompt as there are treated as markers. after the fix only the fisrt "Negative prompt:" will be removed
"""
if skip_fields is None:
skip_fields = shared.opts.infotext_skip_pasting

prompt, negative_prompt, last_line = split_infotext(x)
res = parameters_to_dict(last_line)
res = parse_parameters(res)

# Extract styles from prompt
if shared.opts.infotext_styles != "Ignore":
Expand Down
5 changes: 5 additions & 0 deletions modules/shared_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ def reload_hypernetworks():
shared.hypernetworks = hypernetwork.list_hypernetworks(cmd_opts.hypernetwork_dir)


def list_pnginfo_html_methods():
from modules.extras import pnginfo_html_map
return list(pnginfo_html_map)


def get_infotext_names():
from modules import infotext_utils, shared
res = {}
Expand Down
2 changes: 1 addition & 1 deletion modules/shared_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@
<li>Discard: remove style text from prompt, keep styles dropdown as it is.</li>
<li>Apply if any: remove style text from prompt; if any styles are found in prompt, put them into styles dropdown, otherwise keep it as it is.</li>
</ul>"""),

"png_info_html_style": OptionInfo("Default", "PNG Info style", gr.Radio, lambda: {"choices": shared_items.list_pnginfo_html_methods()}).info("Default -> Parsed"),
}))

options_templates.update(options_section(('ui', "Live previews", "ui"), {
Expand Down
63 changes: 63 additions & 0 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,66 @@ function uiElementInSight(el) {

return isOnScreen;
}

function uiCopyElementAnimate(el) {
el.classList.remove('animate');
setTimeout(() => {
el.classList.add('animate');
}, 0);
setTimeout(() => {
el.classList.remove('animate');
}, 1100);
}

function uiCopyElementText(el) {
var text = el.innerText;
if (text.startsWith('"')) {
text = text.substring(1, text.length - 1).replaceAll('\\n', '\n');
}

navigator.clipboard.writeText(text);
uiCopyElementAnimate(el);
}

function uiCopyRawText(elid) {
var el = document.getElementById(elid);
if (el == null) {
return null;
}

return el.innerText;
}

function uiCopyPngInfo(el, mode) {
var text = null;

if (mode == "Prompt") {
text = uiCopyRawText("pnginfo-positive");
} else if (mode == "Negative") {
text = uiCopyRawText("pnginfo-negative");
} else if (mode == "Settings") {
text = uiCopyRawText("pnginfo-settings");
} else if (mode == "Copy") {
text = "";
var t2 = uiCopyRawText("pnginfo-positive");
if (t2 != null) {
text += t2;
}
t2 = uiCopyRawText("pnginfo-negative");
if (t2 != null) {
text += "\nNegative prompt: " + t2;
}
t2 = uiCopyRawText("pnginfo-settings");
if (t2 != null) {
text += "\n" + t2;
}
if (text == "") {
text = null;
}
}

if (text != null) {
navigator.clipboard.writeText(text);
uiCopyElementAnimate(el);
}
}
Loading