Path: blob/master/modules/images.py
3055 views
from __future__ import annotations12import datetime3import functools4import pytz5import io6import math7import os8from collections import namedtuple9import re1011import numpy as np12import piexif13import piexif.helper14from PIL import Image, ImageFont, ImageDraw, ImageColor, PngImagePlugin, ImageOps15# pillow_avif needs to be imported somewhere in code for it to work16import pillow_avif # noqa: F40117import string18import json19import hashlib2021from modules import sd_samplers, shared, script_callbacks, errors22from modules.paths_internal import roboto_ttf_file23from modules.shared import opts2425LANCZOS = (Image.Resampling.LANCZOS if hasattr(Image, 'Resampling') else Image.LANCZOS)262728def get_font(fontsize: int):29try:30return ImageFont.truetype(opts.font or roboto_ttf_file, fontsize)31except Exception:32return ImageFont.truetype(roboto_ttf_file, fontsize)333435def image_grid(imgs, batch_size=1, rows=None):36if rows is None:37if opts.n_rows > 0:38rows = opts.n_rows39elif opts.n_rows == 0:40rows = batch_size41elif opts.grid_prevent_empty_spots:42rows = math.floor(math.sqrt(len(imgs)))43while len(imgs) % rows != 0:44rows -= 145else:46rows = math.sqrt(len(imgs))47rows = round(rows)48if rows > len(imgs):49rows = len(imgs)5051cols = math.ceil(len(imgs) / rows)5253params = script_callbacks.ImageGridLoopParams(imgs, cols, rows)54script_callbacks.image_grid_callback(params)5556w, h = map(max, zip(*(img.size for img in imgs)))57grid_background_color = ImageColor.getcolor(opts.grid_background_color, 'RGB')58grid = Image.new('RGB', size=(params.cols * w, params.rows * h), color=grid_background_color)5960for i, img in enumerate(params.imgs):61img_w, img_h = img.size62w_offset, h_offset = 0 if img_w == w else (w - img_w) // 2, 0 if img_h == h else (h - img_h) // 263grid.paste(img, box=(i % params.cols * w + w_offset, i // params.cols * h + h_offset))6465return grid666768class Grid(namedtuple("_Grid", ["tiles", "tile_w", "tile_h", "image_w", "image_h", "overlap"])):69@property70def tile_count(self) -> int:71"""72The total number of tiles in the grid.73"""74return sum(len(row[2]) for row in self.tiles)757677def split_grid(image: Image.Image, tile_w: int = 512, tile_h: int = 512, overlap: int = 64) -> Grid:78w, h = image.size7980non_overlap_width = tile_w - overlap81non_overlap_height = tile_h - overlap8283cols = math.ceil((w - overlap) / non_overlap_width)84rows = math.ceil((h - overlap) / non_overlap_height)8586dx = (w - tile_w) / (cols - 1) if cols > 1 else 087dy = (h - tile_h) / (rows - 1) if rows > 1 else 08889grid = Grid([], tile_w, tile_h, w, h, overlap)90for row in range(rows):91row_images = []9293y = int(row * dy)9495if y + tile_h >= h:96y = h - tile_h9798for col in range(cols):99x = int(col * dx)100101if x + tile_w >= w:102x = w - tile_w103104tile = image.crop((x, y, x + tile_w, y + tile_h))105106row_images.append([x, tile_w, tile])107108grid.tiles.append([y, tile_h, row_images])109110return grid111112113def combine_grid(grid):114def make_mask_image(r):115r = r * 255 / grid.overlap116r = r.astype(np.uint8)117return Image.fromarray(r, 'L')118119mask_w = make_mask_image(np.arange(grid.overlap, dtype=np.float32).reshape((1, grid.overlap)).repeat(grid.tile_h, axis=0))120mask_h = make_mask_image(np.arange(grid.overlap, dtype=np.float32).reshape((grid.overlap, 1)).repeat(grid.image_w, axis=1))121122combined_image = Image.new("RGB", (grid.image_w, grid.image_h))123for y, h, row in grid.tiles:124combined_row = Image.new("RGB", (grid.image_w, h))125for x, w, tile in row:126if x == 0:127combined_row.paste(tile, (0, 0))128continue129130combined_row.paste(tile.crop((0, 0, grid.overlap, h)), (x, 0), mask=mask_w)131combined_row.paste(tile.crop((grid.overlap, 0, w, h)), (x + grid.overlap, 0))132133if y == 0:134combined_image.paste(combined_row, (0, 0))135continue136137combined_image.paste(combined_row.crop((0, 0, combined_row.width, grid.overlap)), (0, y), mask=mask_h)138combined_image.paste(combined_row.crop((0, grid.overlap, combined_row.width, h)), (0, y + grid.overlap))139140return combined_image141142143class GridAnnotation:144def __init__(self, text='', is_active=True):145self.text = text146self.is_active = is_active147self.size = None148149150def draw_grid_annotations(im, width, height, hor_texts, ver_texts, margin=0):151152color_active = ImageColor.getcolor(opts.grid_text_active_color, 'RGB')153color_inactive = ImageColor.getcolor(opts.grid_text_inactive_color, 'RGB')154color_background = ImageColor.getcolor(opts.grid_background_color, 'RGB')155156def wrap(drawing, text, font, line_length):157lines = ['']158for word in text.split():159line = f'{lines[-1]} {word}'.strip()160if drawing.textlength(line, font=font) <= line_length:161lines[-1] = line162else:163lines.append(word)164return lines165166def draw_texts(drawing, draw_x, draw_y, lines, initial_fnt, initial_fontsize):167for line in lines:168fnt = initial_fnt169fontsize = initial_fontsize170while drawing.multiline_textsize(line.text, font=fnt)[0] > line.allowed_width and fontsize > 0:171fontsize -= 1172fnt = get_font(fontsize)173drawing.multiline_text((draw_x, draw_y + line.size[1] / 2), line.text, font=fnt, fill=color_active if line.is_active else color_inactive, anchor="mm", align="center")174175if not line.is_active:176drawing.line((draw_x - line.size[0] // 2, draw_y + line.size[1] // 2, draw_x + line.size[0] // 2, draw_y + line.size[1] // 2), fill=color_inactive, width=4)177178draw_y += line.size[1] + line_spacing179180fontsize = (width + height) // 25181line_spacing = fontsize // 2182183fnt = get_font(fontsize)184185pad_left = 0 if sum([sum([len(line.text) for line in lines]) for lines in ver_texts]) == 0 else width * 3 // 4186187cols = im.width // width188rows = im.height // height189190assert cols == len(hor_texts), f'bad number of horizontal texts: {len(hor_texts)}; must be {cols}'191assert rows == len(ver_texts), f'bad number of vertical texts: {len(ver_texts)}; must be {rows}'192193calc_img = Image.new("RGB", (1, 1), color_background)194calc_d = ImageDraw.Draw(calc_img)195196for texts, allowed_width in zip(hor_texts + ver_texts, [width] * len(hor_texts) + [pad_left] * len(ver_texts)):197items = [] + texts198texts.clear()199200for line in items:201wrapped = wrap(calc_d, line.text, fnt, allowed_width)202texts += [GridAnnotation(x, line.is_active) for x in wrapped]203204for line in texts:205bbox = calc_d.multiline_textbbox((0, 0), line.text, font=fnt)206line.size = (bbox[2] - bbox[0], bbox[3] - bbox[1])207line.allowed_width = allowed_width208209hor_text_heights = [sum([line.size[1] + line_spacing for line in lines]) - line_spacing for lines in hor_texts]210ver_text_heights = [sum([line.size[1] + line_spacing for line in lines]) - line_spacing * len(lines) for lines in ver_texts]211212pad_top = 0 if sum(hor_text_heights) == 0 else max(hor_text_heights) + line_spacing * 2213214result = Image.new("RGB", (im.width + pad_left + margin * (cols-1), im.height + pad_top + margin * (rows-1)), color_background)215216for row in range(rows):217for col in range(cols):218cell = im.crop((width * col, height * row, width * (col+1), height * (row+1)))219result.paste(cell, (pad_left + (width + margin) * col, pad_top + (height + margin) * row))220221d = ImageDraw.Draw(result)222223for col in range(cols):224x = pad_left + (width + margin) * col + width / 2225y = pad_top / 2 - hor_text_heights[col] / 2226227draw_texts(d, x, y, hor_texts[col], fnt, fontsize)228229for row in range(rows):230x = pad_left / 2231y = pad_top + (height + margin) * row + height / 2 - ver_text_heights[row] / 2232233draw_texts(d, x, y, ver_texts[row], fnt, fontsize)234235return result236237238def draw_prompt_matrix(im, width, height, all_prompts, margin=0):239prompts = all_prompts[1:]240boundary = math.ceil(len(prompts) / 2)241242prompts_horiz = prompts[:boundary]243prompts_vert = prompts[boundary:]244245hor_texts = [[GridAnnotation(x, is_active=pos & (1 << i) != 0) for i, x in enumerate(prompts_horiz)] for pos in range(1 << len(prompts_horiz))]246ver_texts = [[GridAnnotation(x, is_active=pos & (1 << i) != 0) for i, x in enumerate(prompts_vert)] for pos in range(1 << len(prompts_vert))]247248return draw_grid_annotations(im, width, height, hor_texts, ver_texts, margin)249250251def resize_image(resize_mode, im, width, height, upscaler_name=None):252"""253Resizes an image with the specified resize_mode, width, and height.254255Args:256resize_mode: The mode to use when resizing the image.2570: Resize the image to the specified width and height.2581: Resize the image to fill the specified width and height, maintaining the aspect ratio, and then center the image within the dimensions, cropping the excess.2592: Resize the image to fit within the specified width and height, maintaining the aspect ratio, and then center the image within the dimensions, filling empty with data from image.260im: The image to resize.261width: The width to resize the image to.262height: The height to resize the image to.263upscaler_name: The name of the upscaler to use. If not provided, defaults to opts.upscaler_for_img2img.264"""265266upscaler_name = upscaler_name or opts.upscaler_for_img2img267268def resize(im, w, h):269if upscaler_name is None or upscaler_name == "None" or im.mode == 'L':270return im.resize((w, h), resample=LANCZOS)271272scale = max(w / im.width, h / im.height)273274if scale > 1.0:275upscalers = [x for x in shared.sd_upscalers if x.name == upscaler_name]276if len(upscalers) == 0:277upscaler = shared.sd_upscalers[0]278print(f"could not find upscaler named {upscaler_name or '<empty string>'}, using {upscaler.name} as a fallback")279else:280upscaler = upscalers[0]281282im = upscaler.scaler.upscale(im, scale, upscaler.data_path)283284if im.width != w or im.height != h:285im = im.resize((w, h), resample=LANCZOS)286287return im288289if resize_mode == 0:290res = resize(im, width, height)291292elif resize_mode == 1:293ratio = width / height294src_ratio = im.width / im.height295296src_w = width if ratio > src_ratio else im.width * height // im.height297src_h = height if ratio <= src_ratio else im.height * width // im.width298299resized = resize(im, src_w, src_h)300res = Image.new("RGB", (width, height))301res.paste(resized, box=(width // 2 - src_w // 2, height // 2 - src_h // 2))302303else:304ratio = width / height305src_ratio = im.width / im.height306307src_w = width if ratio < src_ratio else im.width * height // im.height308src_h = height if ratio >= src_ratio else im.height * width // im.width309310resized = resize(im, src_w, src_h)311res = Image.new("RGB", (width, height))312res.paste(resized, box=(width // 2 - src_w // 2, height // 2 - src_h // 2))313314if ratio < src_ratio:315fill_height = height // 2 - src_h // 2316if fill_height > 0:317res.paste(resized.resize((width, fill_height), box=(0, 0, width, 0)), box=(0, 0))318res.paste(resized.resize((width, fill_height), box=(0, resized.height, width, resized.height)), box=(0, fill_height + src_h))319elif ratio > src_ratio:320fill_width = width // 2 - src_w // 2321if fill_width > 0:322res.paste(resized.resize((fill_width, height), box=(0, 0, 0, height)), box=(0, 0))323res.paste(resized.resize((fill_width, height), box=(resized.width, 0, resized.width, height)), box=(fill_width + src_w, 0))324325return res326327328if not shared.cmd_opts.unix_filenames_sanitization:329invalid_filename_chars = '#<>:"/\\|?*\n\r\t'330else:331invalid_filename_chars = '/'332invalid_filename_prefix = ' '333invalid_filename_postfix = ' .'334re_nonletters = re.compile(r'[\s' + string.punctuation + ']+')335re_pattern = re.compile(r"(.*?)(?:\[([^\[\]]+)\]|$)")336re_pattern_arg = re.compile(r"(.*)<([^>]*)>$")337max_filename_part_length = shared.cmd_opts.filenames_max_length338NOTHING_AND_SKIP_PREVIOUS_TEXT = object()339340341def sanitize_filename_part(text, replace_spaces=True):342if text is None:343return None344345if replace_spaces:346text = text.replace(' ', '_')347348text = text.translate({ord(x): '_' for x in invalid_filename_chars})349text = text.lstrip(invalid_filename_prefix)[:max_filename_part_length]350text = text.rstrip(invalid_filename_postfix)351return text352353354@functools.cache355def get_scheduler_str(sampler_name, scheduler_name):356"""Returns {Scheduler} if the scheduler is applicable to the sampler"""357if scheduler_name == 'Automatic':358config = sd_samplers.find_sampler_config(sampler_name)359scheduler_name = config.options.get('scheduler', 'Automatic')360return scheduler_name.capitalize()361362363@functools.cache364def get_sampler_scheduler_str(sampler_name, scheduler_name):365"""Returns the '{Sampler} {Scheduler}' if the scheduler is applicable to the sampler"""366return f'{sampler_name} {get_scheduler_str(sampler_name, scheduler_name)}'367368369def get_sampler_scheduler(p, sampler):370"""Returns '{Sampler} {Scheduler}' / '{Scheduler}' / 'NOTHING_AND_SKIP_PREVIOUS_TEXT'"""371if hasattr(p, 'scheduler') and hasattr(p, 'sampler_name'):372if sampler:373sampler_scheduler = get_sampler_scheduler_str(p.sampler_name, p.scheduler)374else:375sampler_scheduler = get_scheduler_str(p.sampler_name, p.scheduler)376return sanitize_filename_part(sampler_scheduler, replace_spaces=False)377return NOTHING_AND_SKIP_PREVIOUS_TEXT378379380class FilenameGenerator:381replacements = {382'basename': lambda self: self.basename or 'img',383'seed': lambda self: self.seed if self.seed is not None else '',384'seed_first': lambda self: self.seed if self.p.batch_size == 1 else self.p.all_seeds[0],385'seed_last': lambda self: NOTHING_AND_SKIP_PREVIOUS_TEXT if self.p.batch_size == 1 else self.p.all_seeds[-1],386'steps': lambda self: self.p and self.p.steps,387'cfg': lambda self: self.p and self.p.cfg_scale,388'width': lambda self: self.image.width,389'height': lambda self: self.image.height,390'styles': lambda self: self.p and sanitize_filename_part(", ".join([style for style in self.p.styles if not style == "None"]) or "None", replace_spaces=False),391'sampler': lambda self: self.p and sanitize_filename_part(self.p.sampler_name, replace_spaces=False),392'sampler_scheduler': lambda self: self.p and get_sampler_scheduler(self.p, True),393'scheduler': lambda self: self.p and get_sampler_scheduler(self.p, False),394'model_hash': lambda self: getattr(self.p, "sd_model_hash", shared.sd_model.sd_model_hash),395'model_name': lambda self: sanitize_filename_part(shared.sd_model.sd_checkpoint_info.name_for_extra, replace_spaces=False),396'date': lambda self: datetime.datetime.now().strftime('%Y-%m-%d'),397'datetime': lambda self, *args: self.datetime(*args), # accepts formats: [datetime], [datetime<Format>], [datetime<Format><Time Zone>]398'job_timestamp': lambda self: getattr(self.p, "job_timestamp", shared.state.job_timestamp),399'prompt_hash': lambda self, *args: self.string_hash(self.prompt, *args),400'negative_prompt_hash': lambda self, *args: self.string_hash(self.p.negative_prompt, *args),401'full_prompt_hash': lambda self, *args: self.string_hash(f"{self.p.prompt} {self.p.negative_prompt}", *args), # a space in between to create a unique string402'prompt': lambda self: sanitize_filename_part(self.prompt),403'prompt_no_styles': lambda self: self.prompt_no_style(),404'prompt_spaces': lambda self: sanitize_filename_part(self.prompt, replace_spaces=False),405'prompt_words': lambda self: self.prompt_words(),406'batch_number': lambda self: NOTHING_AND_SKIP_PREVIOUS_TEXT if self.p.batch_size == 1 or self.zip else self.p.batch_index + 1,407'batch_size': lambda self: self.p.batch_size,408'generation_number': lambda self: NOTHING_AND_SKIP_PREVIOUS_TEXT if (self.p.n_iter == 1 and self.p.batch_size == 1) or self.zip else self.p.iteration * self.p.batch_size + self.p.batch_index + 1,409'hasprompt': lambda self, *args: self.hasprompt(*args), # accepts formats:[hasprompt<prompt1|default><prompt2>..]410'clip_skip': lambda self: opts.data["CLIP_stop_at_last_layers"],411'denoising': lambda self: self.p.denoising_strength if self.p and self.p.denoising_strength else NOTHING_AND_SKIP_PREVIOUS_TEXT,412'user': lambda self: self.p.user,413'vae_filename': lambda self: self.get_vae_filename(),414'none': lambda self: '', # Overrides the default, so you can get just the sequence number415'image_hash': lambda self, *args: self.image_hash(*args) # accepts formats: [image_hash<length>] default full hash416}417default_time_format = '%Y%m%d%H%M%S'418419def __init__(self, p, seed, prompt, image, zip=False, basename=""):420self.p = p421self.seed = seed422self.prompt = prompt423self.image = image424self.zip = zip425self.basename = basename426427def get_vae_filename(self):428"""Get the name of the VAE file."""429430import modules.sd_vae as sd_vae431432if sd_vae.loaded_vae_file is None:433return "NoneType"434435file_name = os.path.basename(sd_vae.loaded_vae_file)436split_file_name = file_name.split('.')437if len(split_file_name) > 1 and split_file_name[0] == '':438return split_file_name[1] # if the first character of the filename is "." then [1] is obtained.439else:440return split_file_name[0]441442443def hasprompt(self, *args):444lower = self.prompt.lower()445if self.p is None or self.prompt is None:446return None447outres = ""448for arg in args:449if arg != "":450division = arg.split("|")451expected = division[0].lower()452default = division[1] if len(division) > 1 else ""453if lower.find(expected) >= 0:454outres = f'{outres}{expected}'455else:456outres = outres if default == "" else f'{outres}{default}'457return sanitize_filename_part(outres)458459def prompt_no_style(self):460if self.p is None or self.prompt is None:461return None462463prompt_no_style = self.prompt464for style in shared.prompt_styles.get_style_prompts(self.p.styles):465if style:466for part in style.split("{prompt}"):467prompt_no_style = prompt_no_style.replace(part, "").replace(", ,", ",").strip().strip(',')468469prompt_no_style = prompt_no_style.replace(style, "").strip().strip(',').strip()470471return sanitize_filename_part(prompt_no_style, replace_spaces=False)472473def prompt_words(self):474words = [x for x in re_nonletters.split(self.prompt or "") if x]475if len(words) == 0:476words = ["empty"]477return sanitize_filename_part(" ".join(words[0:opts.directories_max_prompt_words]), replace_spaces=False)478479def datetime(self, *args):480time_datetime = datetime.datetime.now()481482time_format = args[0] if (args and args[0] != "") else self.default_time_format483try:484time_zone = pytz.timezone(args[1]) if len(args) > 1 else None485except pytz.exceptions.UnknownTimeZoneError:486time_zone = None487488time_zone_time = time_datetime.astimezone(time_zone)489try:490formatted_time = time_zone_time.strftime(time_format)491except (ValueError, TypeError):492formatted_time = time_zone_time.strftime(self.default_time_format)493494return sanitize_filename_part(formatted_time, replace_spaces=False)495496def image_hash(self, *args):497length = int(args[0]) if (args and args[0] != "") else None498return hashlib.sha256(self.image.tobytes()).hexdigest()[0:length]499500def string_hash(self, text, *args):501length = int(args[0]) if (args and args[0] != "") else 8502return hashlib.sha256(text.encode()).hexdigest()[0:length]503504def apply(self, x):505res = ''506507for m in re_pattern.finditer(x):508text, pattern = m.groups()509510if pattern is None:511res += text512continue513514pattern_args = []515while True:516m = re_pattern_arg.match(pattern)517if m is None:518break519520pattern, arg = m.groups()521pattern_args.insert(0, arg)522523fun = self.replacements.get(pattern.lower())524if fun is not None:525try:526replacement = fun(self, *pattern_args)527except Exception:528replacement = None529errors.report(f"Error adding [{pattern}] to filename", exc_info=True)530531if replacement == NOTHING_AND_SKIP_PREVIOUS_TEXT:532continue533elif replacement is not None:534res += text + str(replacement)535continue536537res += f'{text}[{pattern}]'538539return res540541542def get_next_sequence_number(path, basename):543"""544Determines and returns the next sequence number to use when saving an image in the specified directory.545546The sequence starts at 0.547"""548result = -1549if basename != '':550basename = f"{basename}-"551552prefix_length = len(basename)553for p in os.listdir(path):554if p.startswith(basename):555parts = os.path.splitext(p[prefix_length:])[0].split('-') # splits the filename (removing the basename first if one is defined, so the sequence number is always the first element)556try:557result = max(int(parts[0]), result)558except ValueError:559pass560561return result + 1562563564def save_image_with_geninfo(image, geninfo, filename, extension=None, existing_pnginfo=None, pnginfo_section_name='parameters'):565"""566Saves image to filename, including geninfo as text information for generation info.567For PNG images, geninfo is added to existing pnginfo dictionary using the pnginfo_section_name argument as key.568For JPG images, there's no dictionary and geninfo just replaces the EXIF description.569"""570571if extension is None:572extension = os.path.splitext(filename)[1]573574image_format = Image.registered_extensions()[extension]575576if extension.lower() == '.png':577existing_pnginfo = existing_pnginfo or {}578if opts.enable_pnginfo:579existing_pnginfo[pnginfo_section_name] = geninfo580581if opts.enable_pnginfo:582pnginfo_data = PngImagePlugin.PngInfo()583for k, v in (existing_pnginfo or {}).items():584pnginfo_data.add_text(k, str(v))585else:586pnginfo_data = None587588image.save(filename, format=image_format, quality=opts.jpeg_quality, pnginfo=pnginfo_data)589590elif extension.lower() in (".jpg", ".jpeg", ".webp"):591if image.mode == 'RGBA':592image = image.convert("RGB")593elif image.mode == 'I;16':594image = image.point(lambda p: p * 0.0038910505836576).convert("RGB" if extension.lower() == ".webp" else "L")595596image.save(filename, format=image_format, quality=opts.jpeg_quality, lossless=opts.webp_lossless)597598if opts.enable_pnginfo and geninfo is not None:599exif_bytes = piexif.dump({600"Exif": {601piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(geninfo or "", encoding="unicode")602},603})604605piexif.insert(exif_bytes, filename)606elif extension.lower() == '.avif':607if opts.enable_pnginfo and geninfo is not None:608exif_bytes = piexif.dump({609"Exif": {610piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(geninfo or "", encoding="unicode")611},612})613else:614exif_bytes = None615616image.save(filename,format=image_format, quality=opts.jpeg_quality, exif=exif_bytes)617elif extension.lower() == ".gif":618image.save(filename, format=image_format, comment=geninfo)619else:620image.save(filename, format=image_format, quality=opts.jpeg_quality)621622623def save_image(image, path, basename, seed=None, prompt=None, extension='png', info=None, short_filename=False, no_prompt=False, grid=False, pnginfo_section_name='parameters', p=None, existing_info=None, forced_filename=None, suffix="", save_to_dirs=None):624"""Save an image.625626Args:627image (`PIL.Image`):628The image to be saved.629path (`str`):630The directory to save the image. Note, the option `save_to_dirs` will make the image to be saved into a sub directory.631basename (`str`):632The base filename which will be applied to `filename pattern`.633seed, prompt, short_filename,634extension (`str`):635Image file extension, default is `png`.636pngsectionname (`str`):637Specify the name of the section which `info` will be saved in.638info (`str` or `PngImagePlugin.iTXt`):639PNG info chunks.640existing_info (`dict`):641Additional PNG info. `existing_info == {pngsectionname: info, ...}`642no_prompt:643TODO I don't know its meaning.644p (`StableDiffusionProcessing`)645forced_filename (`str`):646If specified, `basename` and filename pattern will be ignored.647save_to_dirs (bool):648If true, the image will be saved into a subdirectory of `path`.649650Returns: (fullfn, txt_fullfn)651fullfn (`str`):652The full path of the saved imaged.653txt_fullfn (`str` or None):654If a text file is saved for this image, this will be its full path. Otherwise None.655"""656namegen = FilenameGenerator(p, seed, prompt, image, basename=basename)657658# WebP and JPG formats have maximum dimension limits of 16383 and 65535 respectively. switch to PNG which has a much higher limit659if (image.height > 65535 or image.width > 65535) and extension.lower() in ("jpg", "jpeg") or (image.height > 16383 or image.width > 16383) and extension.lower() == "webp":660print('Image dimensions too large; saving as PNG')661extension = "png"662663if save_to_dirs is None:664save_to_dirs = (grid and opts.grid_save_to_dirs) or (not grid and opts.save_to_dirs and not no_prompt)665666if save_to_dirs:667dirname = namegen.apply(opts.directories_filename_pattern or "[prompt_words]").lstrip(' ').rstrip('\\ /')668path = os.path.join(path, dirname)669670os.makedirs(path, exist_ok=True)671672if forced_filename is None:673if short_filename or seed is None:674file_decoration = ""675elif opts.save_to_dirs:676file_decoration = opts.samples_filename_pattern or "[seed]"677else:678file_decoration = opts.samples_filename_pattern or "[seed]-[prompt_spaces]"679680file_decoration = namegen.apply(file_decoration) + suffix681682add_number = opts.save_images_add_number or file_decoration == ''683684if file_decoration != "" and add_number:685file_decoration = f"-{file_decoration}"686687if add_number:688basecount = get_next_sequence_number(path, basename)689fullfn = None690for i in range(500):691fn = f"{basecount + i:05}" if basename == '' else f"{basename}-{basecount + i:04}"692fullfn = os.path.join(path, f"{fn}{file_decoration}.{extension}")693if not os.path.exists(fullfn):694break695else:696fullfn = os.path.join(path, f"{file_decoration}.{extension}")697else:698fullfn = os.path.join(path, f"{forced_filename}.{extension}")699700pnginfo = existing_info or {}701if info is not None:702pnginfo[pnginfo_section_name] = info703704params = script_callbacks.ImageSaveParams(image, p, fullfn, pnginfo)705script_callbacks.before_image_saved_callback(params)706707image = params.image708fullfn = params.filename709info = params.pnginfo.get(pnginfo_section_name, None)710711def _atomically_save_image(image_to_save, filename_without_extension, extension):712"""713save image with .tmp extension to avoid race condition when another process detects new image in the directory714"""715temp_file_path = f"{filename_without_extension}.tmp"716717save_image_with_geninfo(image_to_save, info, temp_file_path, extension, existing_pnginfo=params.pnginfo, pnginfo_section_name=pnginfo_section_name)718719filename = filename_without_extension + extension720if shared.opts.save_images_replace_action != "Replace":721n = 0722while os.path.exists(filename):723n += 1724filename = f"{filename_without_extension}-{n}{extension}"725os.replace(temp_file_path, filename)726727fullfn_without_extension, extension = os.path.splitext(params.filename)728if hasattr(os, 'statvfs'):729max_name_len = os.statvfs(path).f_namemax730fullfn_without_extension = fullfn_without_extension[:max_name_len - max(4, len(extension))]731params.filename = fullfn_without_extension + extension732fullfn = params.filename733_atomically_save_image(image, fullfn_without_extension, extension)734735image.already_saved_as = fullfn736737oversize = image.width > opts.target_side_length or image.height > opts.target_side_length738if opts.export_for_4chan and (oversize or os.stat(fullfn).st_size > opts.img_downscale_threshold * 1024 * 1024):739ratio = image.width / image.height740resize_to = None741if oversize and ratio > 1:742resize_to = round(opts.target_side_length), round(image.height * opts.target_side_length / image.width)743elif oversize:744resize_to = round(image.width * opts.target_side_length / image.height), round(opts.target_side_length)745746if resize_to is not None:747try:748# Resizing image with LANCZOS could throw an exception if e.g. image mode is I;16749image = image.resize(resize_to, LANCZOS)750except Exception:751image = image.resize(resize_to)752try:753_atomically_save_image(image, fullfn_without_extension, ".jpg")754except Exception as e:755errors.display(e, "saving image as downscaled JPG")756757if opts.save_txt and info is not None:758txt_fullfn = f"{fullfn_without_extension}.txt"759with open(txt_fullfn, "w", encoding="utf8") as file:760file.write(f"{info}\n")761else:762txt_fullfn = None763764script_callbacks.image_saved_callback(params)765766return fullfn, txt_fullfn767768769IGNORED_INFO_KEYS = {770'jfif', 'jfif_version', 'jfif_unit', 'jfif_density', 'dpi', 'exif',771'loop', 'background', 'timestamp', 'duration', 'progressive', 'progression',772'icc_profile', 'chromaticity', 'photoshop',773}774775776def read_info_from_image(image: Image.Image) -> tuple[str | None, dict]:777items = (image.info or {}).copy()778779geninfo = items.pop('parameters', None)780781if "exif" in items:782exif_data = items["exif"]783try:784exif = piexif.load(exif_data)785except OSError:786# memory / exif was not valid so piexif tried to read from a file787exif = None788exif_comment = (exif or {}).get("Exif", {}).get(piexif.ExifIFD.UserComment, b'')789try:790exif_comment = piexif.helper.UserComment.load(exif_comment)791except ValueError:792exif_comment = exif_comment.decode('utf8', errors="ignore")793794if exif_comment:795geninfo = exif_comment796elif "comment" in items: # for gif797if isinstance(items["comment"], bytes):798geninfo = items["comment"].decode('utf8', errors="ignore")799else:800geninfo = items["comment"]801802for field in IGNORED_INFO_KEYS:803items.pop(field, None)804805if items.get("Software", None) == "NovelAI":806try:807json_info = json.loads(items["Comment"])808sampler = sd_samplers.samplers_map.get(json_info["sampler"], "Euler a")809810geninfo = f"""{items["Description"]}811Negative prompt: {json_info["uc"]}812Steps: {json_info["steps"]}, Sampler: {sampler}, CFG scale: {json_info["scale"]}, Seed: {json_info["seed"]}, Size: {image.width}x{image.height}, Clip skip: 2, ENSD: 31337"""813except Exception:814errors.report("Error parsing NovelAI image generation parameters", exc_info=True)815816return geninfo, items817818819def image_data(data):820import gradio as gr821822try:823image = read(io.BytesIO(data))824textinfo, _ = read_info_from_image(image)825return textinfo, None826except Exception:827pass828829try:830text = data.decode('utf8')831assert len(text) < 10000832return text, None833834except Exception:835pass836837return gr.update(), None838839840def flatten(img, bgcolor):841"""replaces transparency with bgcolor (example: "#ffffff"), returning an RGB mode image with no transparency"""842843if img.mode == "RGBA":844background = Image.new('RGBA', img.size, bgcolor)845background.paste(img, mask=img)846img = background847848return img.convert('RGB')849850851def read(fp, **kwargs):852image = Image.open(fp, **kwargs)853image = fix_image(image)854855return image856857858def fix_image(image: Image.Image):859if image is None:860return None861862try:863image = ImageOps.exif_transpose(image)864image = fix_png_transparency(image)865except Exception:866pass867868return image869870871def fix_png_transparency(image: Image.Image):872if image.mode not in ("RGB", "P") or not isinstance(image.info.get("transparency"), bytes):873return image874875image = image.convert("RGBA")876return image877878879