diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abe3a579..4c985f41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,40 @@ - Include reference styles - Quick apply style - Add refine workflow in img2img +- Gallery send to buttons row +- Gallery client-side caching? thumbnails, metadata +- Gallery search by metadata ## Update for 2024-03-20 -Gallery: -- All operations are async, non-blocking and auto-cancelled as needed - This gives up a little bit of raw performance, but there is no "wait for current folder which has 10k images" - Right now enumerate is ~100-300 images/sec which is sufficient to show first screen of images near-instant -- Images are loaded using lazy-loading, meaning on-demand when they are scrolled into view - But...If you want to have 10k images in a folder and then scroll-to-bottom, well... -- Majority of work is done client-side in browser without needing for typical Gradio round-trip -- Nothing cached by choice, images are always up-to-date (I may add thumbnail caching later) -- Search is matching any part of folder/filename and image properties, but not image metadata (yet) -- Search allows for syntax like `size > 1000000` or `width > 1000` - Operators are `<>=` and keys are `size`, `width`, `height`, `mtime` -- Sort and search can easily handle 20k images-per-sec -- Folders are scanned recursively -- Optional user-defined folders: settings -> image options -> image browser -- Thumbnails can be fixed or varible width, set in settings -> image options -> image browser -Gallery ToDo: -- Send to buttons row -- Client-side caching? thumbnails, metadata -- Search by metadata - -Changes: +**Features**: +- Gallery: + - All operations are async, non-blocking and auto-cancelled as needed + This gives up a little bit of raw performance, but there is no "wait for current folder which has 10k images" + Right now enumerate is ~100-300 images/sec which is sufficient to show first screen of images near-instant + - Images are loaded using lazy-loading, meaning on-demand when they are scrolled into view + But...If you want to have 10k images in a folder and then scroll-to-bottom, well... + - Majority of work is done client-side in browser without needing for typical Gradio round-trip + - Nothing cached by choice, images are always up-to-date (I may add thumbnail caching later) + - Search is matching any part of folder/filename and image properties, but not image metadata (yet) + - Search allows for syntax like `size > 1000000` or `width > 1000` + Operators are `<>=` and keys are `size`, `width`, `height`, `mtime` + - Sort and search can easily handle 20k images-per-sec + - Folders are scanned recursively + - Optional user-defined folders: settings -> image options -> image browser + - Thumbnails can be fixed or varible width, set in settings -> image options -> image browser +**Changes**: - Removed built-in extensions: *ControlNet* and *Image-Browser* as both *image-browser* and *controlnet* have native equivalents both can still be installed by user if desired -Improvements: +**Improvements**: - Styles apply wildcards to params - Make metadata in full screen viewer optional +Fixes: +- Propmpt params parser +- Fix image save without metadata + ## Update for 2024-03-19 ### Highlights 2024-03-19 diff --git a/cli/image-exif.py b/cli/image-exif.py index fcbbb44c8..69f20612a 100755 --- a/cli/image-exif.py +++ b/cli/image-exif.py @@ -18,12 +18,13 @@ def unquote(text): return text -def parse_generation_parameters(infotext): # copied from modules.generation_parameters_copypaste +def parse_generation_parameters(infotext): if not isinstance(infotext, str): return {} - re_param = re.compile(r'\s*([\w ]+):\s*("(?:\\"[^,]|\\"|\\|[^\"])+"|[^,]*)(?:,|$)') # multi-word: value re_size = re.compile(r"^(\d+)x(\d+)$") # int x int + basic_params = ['steps', 'seed', 'width', 'height', 'sampler', 'size', 'cfg scale', 'hires'] # first param is one of those + sanitized = infotext.replace('prompt:', 'Prompt:').replace('negative prompt:', 'Negative prompt:').replace('Negative Prompt', 'Negative prompt') # cleanup everything in brackets so re_params can work sanitized = re.sub(r'<[^>]*>', lambda match: ' ' * len(match.group()), sanitized) sanitized = re.sub(r'\([^)]*\)', lambda match: ' ' * len(match.group()), sanitized) @@ -31,7 +32,10 @@ def parse_generation_parameters(infotext): # copied from modules.generation_para params = dict(re_param.findall(sanitized)) params = { k.strip():params[k].strip() for k in params if k.lower() not in ['hashes', 'lora', 'embeddings', 'prompt', 'negative prompt']} # remove some keys - first_param = next(iter(params)) if params else None + first_param, first_param_idx = next((s, i) for i, s in enumerate(params) if any(x in s.lower() for x in basic_params)) + if first_param_idx > 0: + for _i in range(first_param_idx): + params.pop(next(iter(params))) params_idx = sanitized.find(f'{first_param}:') if first_param else -1 negative_idx = infotext.find("Negative prompt:") @@ -81,6 +85,8 @@ class Exif: # pylint: disable=single-string-used-for-slots try: exif_dict = dict(img._getexif().items()) # pylint: disable=protected-access except Exception: + pass + if not exif_dict: exif_dict = dict(img.info.items()) for key, val in exif_dict.items(): if isinstance(val, bytes): # decode bytestring diff --git a/modules/generation_parameters_copypaste.py b/modules/generation_parameters_copypaste.py index 653279b91..36356f90b 100644 --- a/modules/generation_parameters_copypaste.py +++ b/modules/generation_parameters_copypaste.py @@ -192,6 +192,8 @@ def parse_generation_parameters(infotext): debug(f'Parse infotext: {infotext}') re_param = re.compile(r'\s*([\w ]+):\s*("(?:\\"[^,]|\\"|\\|[^\"])+"|[^,]*)(?:,|$)') # multi-word: value re_size = re.compile(r"^(\d+)x(\d+)$") # int x int + basic_params = ['steps', 'seed', 'width', 'height', 'sampler', 'size', 'cfg scale'] # first param is one of those + sanitized = infotext.replace('prompt:', 'Prompt:').replace('negative prompt:', 'Negative prompt:').replace('Negative Prompt', 'Negative prompt') # cleanup everything in brackets so re_params can work sanitized = re.sub(r'<[^>]*>', lambda match: ' ' * len(match.group()), sanitized) sanitized = re.sub(r'\([^)]*\)', lambda match: ' ' * len(match.group()), sanitized) @@ -200,7 +202,10 @@ def parse_generation_parameters(infotext): params = dict(re_param.findall(sanitized)) debug(f"Parse params: {params}") params = { k.strip():params[k].strip() for k in params if k.lower() not in ['hashes', 'lora', 'embeddings', 'prompt', 'negative prompt']} # remove some keys - first_param = next(iter(params)) if params else None + first_param, first_param_idx = next((s, i) for i, s in enumerate(params) if any(x in s.lower() for x in basic_params)) + if first_param_idx > 0: + for _i in range(first_param_idx): + params.pop(next(iter(params))) params_idx = sanitized.find(f'{first_param}:') if first_param else -1 negative_idx = infotext.find("Negative prompt:") diff --git a/modules/images.py b/modules/images.py index 81c04e5f4..8ff381bf5 100644 --- a/modules/images.py +++ b/modules/images.py @@ -548,6 +548,7 @@ def atomically_save_image(): image = set_watermark(image, shared.opts.image_watermark) size = os.path.getsize(fn) if os.path.exists(fn) else 0 shared.log.info(f'Saving: image="{fn}" type={image_format} resolution={image.width}x{image.height} size={size}') + exifinfo = (exifinfo or "") if shared.opts.image_metadata else "" # additional metadata saved in files if shared.opts.save_txt and len(exifinfo) > 0: try: @@ -557,7 +558,6 @@ def atomically_save_image(): except Exception as e: shared.log.warning(f'Saving failed: description={filename_txt} {e}') # actual save - exifinfo = (exifinfo or "") if shared.opts.image_metadata else "" if image_format == 'PNG': pnginfo_data = PngImagePlugin.PngInfo() for k, v in params.pnginfo.items(): @@ -569,19 +569,22 @@ def atomically_save_image(): image = image.convert("RGB") elif image.mode == 'I;16': image = image.point(lambda p: p * 0.0038910505836576).convert("L") - exif_bytes = piexif.dump({ "Exif": { piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(exifinfo, encoding="unicode") } }) - save_args = { 'optimize': True, 'quality': shared.opts.jpeg_quality, 'exif': exif_bytes if shared.opts.image_metadata else None } + save_args = { 'optimize': True, 'quality': shared.opts.jpeg_quality } + if shared.opts.image_metadata: + save_args['exif'] = piexif.dump({ "Exif": { piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(exifinfo, encoding="unicode") } }) elif image_format == 'WEBP': if image.mode == 'I;16': image = image.point(lambda p: p * 0.0038910505836576).convert("RGB") - exif_bytes = piexif.dump({ "Exif": { piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(exifinfo, encoding="unicode") } }) - save_args = { 'optimize': True, 'quality': shared.opts.jpeg_quality, 'exif': exif_bytes if shared.opts.image_metadata else None, 'lossless': shared.opts.webp_lossless } + save_args = { 'optimize': True, 'quality': shared.opts.jpeg_quality, 'lossless': shared.opts.webp_lossless } + if shared.opts.image_metadata: + save_args['exif'] = piexif.dump({ "Exif": { piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(exifinfo, encoding="unicode") } }) else: save_args = { 'quality': shared.opts.jpeg_quality } try: image.save(fn, format=image_format, **save_args) except Exception as e: - shared.log.error(f'Saving failed: file="{fn}" format={image_format} {e}') + shared.log.error(f'Saving failed: file="{fn}" format={image_format} args={save_args} {e}') + errors.display(e, 'Image save') if shared.opts.save_log_fn != '' and len(exifinfo) > 0: fn = os.path.join(paths.data_path, shared.opts.save_log_fn) if not fn.endswith('.json'):