From bbd631f9d477c3b6238fdbe9c573b553b9c274da Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Tue, 28 Jan 2025 10:12:11 -0500 Subject: [PATCH 01/11] adding jxl possibly --- requirements.txt | 1 + .../auto_captioning/auto_captioning_model.py | 1 + taggui/models/image_list_model.py | 40 ++++++++++--------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6827235a..27a89ad1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ bitsandbytes==0.45.0 ExifRead==3.0.0 imagesize==1.4.1 pillow==11.0.0 +pillow-jxl-plugin~=1.3.1 pyparsing==3.2.0 PySide6==6.8.1 transformers==4.45.2 diff --git a/taggui/auto_captioning/auto_captioning_model.py b/taggui/auto_captioning/auto_captioning_model.py index 16691492..fcfc62a3 100644 --- a/taggui/auto_captioning/auto_captioning_model.py +++ b/taggui/auto_captioning/auto_captioning_model.py @@ -5,6 +5,7 @@ import numpy as np import torch +import pillow_jxl from PIL import Image as PilImage from PIL.ImageOps import exif_transpose from transformers import (AutoModelForVision2Seq, AutoProcessor, diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 09e9127d..8b1afdf1 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -7,7 +7,7 @@ from pathlib import Path import exifread -import imagesize +#import imagesize from PySide6.QtCore import (QAbstractListModel, QModelIndex, QSize, Qt, Signal, Slot) from PySide6.QtGui import QIcon, QImageReader, QPixmap @@ -16,6 +16,8 @@ from utils.image import Image from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize +import pillow_jxl +from PIL import Image as pilimage UNDO_STACK_SIZE = 32 @@ -124,23 +126,25 @@ def load_directory(self, directory_path: Path): if path.suffix == '.txt'} for image_path in image_paths: try: - dimensions = imagesize.get(image_path) - # Check the Exif orientation tag and rotate the dimensions if - # necessary. - with open(image_path, 'rb') as image_file: - try: - exif_tags = exifread.process_file( - image_file, details=False, - stop_tag='Image Orientation') - if 'Image Orientation' in exif_tags: - orientations = (exif_tags['Image Orientation'] - .values) - if any(value in orientations - for value in (5, 6, 7, 8)): - dimensions = (dimensions[1], dimensions[0]) - except Exception as exception: - print(f'Failed to get Exif tags for {image_path}: ' - f'{exception}', file=sys.stderr) + #dimensions = imagesize.get(image_path) + with pilimage.open(image_path) as ci: + dimensions = ci.size + # Check the Exif orientation tag and rotate the dimensions if + # necessary. + with open(image_path, 'rb') as image_file: + try: + exif_tags = exifread.process_file( + image_file, details=False, + stop_tag='Image Orientation') + if 'Image Orientation' in exif_tags: + orientations = (exif_tags['Image Orientation'] + .values) + if any(value in orientations + for value in (5, 6, 7, 8)): + dimensions = (dimensions[1], dimensions[0]) + except Exception as exception: + print(f'Failed to get Exif tags for {image_path}: ' + f'{exception}', file=sys.stderr) except (ValueError, OSError) as exception: print(f'Failed to get dimensions for {image_path}: ' f'{exception}', file=sys.stderr) From 96a0b841484c4b261abb7ae1f5b9a4e28597eaf3 Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Tue, 28 Jan 2025 12:56:09 -0500 Subject: [PATCH 02/11] bunch of changes from gemini --- taggui/models/image_list_model.py | 234 +++++++++++++++--------------- 1 file changed, 121 insertions(+), 113 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 8b1afdf1..737d7647 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -5,9 +5,9 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path +from typing import List, Set, Tuple import exifread -#import imagesize from PySide6.QtCore import (QAbstractListModel, QModelIndex, QSize, Qt, Signal, Slot) from PySide6.QtGui import QIcon, QImageReader, QPixmap @@ -22,24 +22,21 @@ UNDO_STACK_SIZE = 32 -def get_file_paths(directory_path: Path) -> set[Path]: +def get_file_paths(directory_path: Path) -> Set[Path]: """ - Recursively get all file paths in a directory, including those in - subdirectories. + Recursively get all file paths in a directory, including subdirectories. """ file_paths = set() - for path in directory_path.iterdir(): + for path in directory_path.rglob("*"): # Use rglob for recursive search if path.is_file(): file_paths.add(path) - elif path.is_dir(): - file_paths.update(get_file_paths(path)) return file_paths @dataclass class HistoryItem: action_name: str - tags: list[list[str]] + tags: List[List[str]] should_ask_for_confirmation: bool @@ -56,33 +53,30 @@ def __init__(self, image_list_image_width: int, tag_separator: str): super().__init__() self.image_list_image_width = image_list_image_width self.tag_separator = tag_separator - self.images: list[Image] = [] + self.images: List[Image] = [] self.undo_stack = deque(maxlen=UNDO_STACK_SIZE) - self.redo_stack = [] + self.redo_stack: List[HistoryItem] = [] # Type hint for clarity self.proxy_image_list_model = None self.image_list_selection_model = None def rowCount(self, parent=None) -> int: return len(self.images) - def data(self, index, role=None) -> Image | str | QIcon | QSize: + def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | None: # Added None to possible return type + if not index.isValid() or index.row() >= len(self.images): #Handle invalid index + return None image = self.images[index.row()] if role == Qt.ItemDataRole.UserRole: return image if role == Qt.ItemDataRole.DisplayRole: - # The text shown next to the thumbnail in the image list. text = image.path.name if image.tags: - caption = self.tag_separator.join(image.tags) - text += f'\n{caption}' + text += f'\n{self.tag_separator.join(image.tags)}' return text if role == Qt.ItemDataRole.DecorationRole: - # The thumbnail. If the image already has a thumbnail stored, use - # it. Otherwise, generate a thumbnail and save it to the image. if image.thumbnail: return image.thumbnail image_reader = QImageReader(str(image.path)) - # Rotate the image based on the orientation tag. image_reader.setAutoTransform(True) pixmap = QPixmap.fromImageReader(image_reader).scaledToWidth( self.image_list_image_width, @@ -98,39 +92,37 @@ def data(self, index, role=None) -> Image | str | QIcon | QSize: return QSize(self.image_list_image_width, self.image_list_image_width) width, height = dimensions - # Scale the dimensions to the image width. return QSize(self.image_list_image_width, int(self.image_list_image_width * height / width)) + return None # Added return None for clarity def load_directory(self, directory_path: Path): + self.beginResetModel() # Use begin/end reset for efficiency self.images.clear() self.undo_stack.clear() self.redo_stack.clear() self.update_undo_and_redo_actions_requested.emit() + file_paths = get_file_paths(directory_path) settings = get_settings() image_suffixes_string = settings.value( 'image_list_file_formats', defaultValue=DEFAULT_SETTINGS['image_list_file_formats'], type=str) - image_suffixes = [] - for suffix in image_suffixes_string.split(','): - suffix = suffix.strip().lower() - if not suffix.startswith('.'): - suffix = '.' + suffix - image_suffixes.append(suffix) + + image_suffixes = [ + suffix.strip().lower() if suffix.startswith('.') else f'.{suffix.strip().lower()}' + for suffix in image_suffixes_string.split(',') + ] # Optimized list comprehension + image_paths = {path for path in file_paths if path.suffix.lower() in image_suffixes} - # Comparing paths is slow on some systems, so convert the paths to - # strings. text_file_path_strings = {str(path) for path in file_paths if path.suffix == '.txt'} + for image_path in image_paths: try: - #dimensions = imagesize.get(image_path) with pilimage.open(image_path) as ci: dimensions = ci.size - # Check the Exif orientation tag and rotate the dimensions if - # necessary. with open(image_path, 'rb') as image_file: try: exif_tags = exifread.process_file( @@ -149,21 +141,22 @@ def load_directory(self, directory_path: Path): print(f'Failed to get dimensions for {image_path}: ' f'{exception}', file=sys.stderr) dimensions = None + tags = [] text_file_path = image_path.with_suffix('.txt') if str(text_file_path) in text_file_path_strings: - # `errors='replace'` inserts a replacement marker such as '?' - # when there is malformed data. - caption = text_file_path.read_text(encoding='utf-8', - errors='replace') - if caption: - tags = caption.split(self.tag_separator) - tags = [tag.strip() for tag in tags] - tags = [tag for tag in tags if tag] + try: + caption = text_file_path.read_text(encoding='utf-8', errors='replace') + if caption: + tags = [tag.strip() for tag in caption.split(self.tag_separator) if tag.strip()] # Optimized tag creation + except Exception as exception: + print(f'Failed to read caption for {text_file_path}: {exception}', file=sys.stderr) + image = Image(image_path, dimensions, tags) self.images.append(image) + self.images.sort(key=lambda image_: image_.path) - self.modelReset.emit() + self.endResetModel() # Use begin/end reset for efficiency def add_to_undo_stack(self, action_name: str, should_ask_for_confirmation: bool): @@ -180,23 +173,21 @@ def write_image_tags_to_disk(self, image: Image): self.tag_separator.join(image.tags), encoding='utf-8', errors='replace') except OSError: - error_message_box = QMessageBox() - error_message_box.setWindowTitle('Error') - error_message_box.setIcon(QMessageBox.Icon.Critical) - error_message_box.setText(f'Failed to save tags for {image.path}.') - error_message_box.exec() + QMessageBox.critical(None, 'Error', + f'Failed to save tags for {image.path}.') # Simpler error message def restore_history_tags(self, is_undo: bool): if is_undo: source_stack = self.undo_stack destination_stack = self.redo_stack else: - # Redo. source_stack = self.redo_stack destination_stack = self.undo_stack + if not source_stack: return - history_item = source_stack[-1] + + history_item = source_stack.pop() if history_item.should_ask_for_confirmation: undo_or_redo_string = 'Undo' if is_undo else 'Redo' reply = get_confirmation_dialog_reply( @@ -205,19 +196,19 @@ def restore_history_tags(self, is_undo: bool): f'"{history_item.action_name}"?') if reply != QMessageBox.StandardButton.Yes: return - source_stack.pop() - tags = [image.tags for image in self.images] + destination_stack.append(HistoryItem( - history_item.action_name, tags, + history_item.action_name, [image.tags for image in self.images], # Optimized tag capture history_item.should_ask_for_confirmation)) + changed_image_indices = [] for image_index, (image, history_image_tags) in enumerate( zip(self.images, history_item.tags)): - if image.tags == history_image_tags: - continue - changed_image_indices.append(image_index) - image.tags = history_image_tags - self.write_image_tags_to_disk(image) + if image.tags != history_image_tags: + changed_image_indices.append(image_index) + image.tags = history_image_tags + self.write_image_tags_to_disk(image) + if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -239,11 +230,14 @@ def is_image_in_scope(self, scope: Scope | str, image_index: int, return True if scope == Scope.FILTERED_IMAGES: return self.proxy_image_list_model.is_image_in_filtered_images( - image) + image) if self.proxy_image_list_model else False # Handle potential None if scope == Scope.SELECTED_IMAGES: - proxy_index = self.proxy_image_list_model.mapFromSource( - self.index(image_index)) - return self.image_list_selection_model.isSelected(proxy_index) + if self.proxy_image_list_model and self.image_list_selection_model: #Handle potential None + proxy_index = self.proxy_image_list_model.mapFromSource( + self.index(image_index)) + return self.image_list_selection_model.isSelected(proxy_index) + return False + return False def get_text_match_count(self, text: str, scope: Scope | str, whole_tags_only: bool, use_regex: bool) -> int: @@ -254,10 +248,10 @@ def get_text_match_count(self, text: str, scope: Scope | str, continue if whole_tags_only: if use_regex: - match_count += len([ - tag for tag in image.tags + match_count += sum( + 1 for tag in image.tags if re.fullmatch(pattern=text, string=tag) - ]) + ) else: match_count += image.tags.count(text) else: @@ -293,9 +287,13 @@ def find_and_replace(self, find_text: str, replace_text: str, if find_text not in caption: continue caption = caption.replace(find_text, replace_text) - changed_image_indices.append(image_index) - image.tags = caption.split(self.tag_separator) - self.write_image_tags_to_disk(image) + + new_tags = caption.split(self.tag_separator) + if new_tags != image.tags: + changed_image_indices.append(image_index) + image.tags = new_tags + self.write_image_tags_to_disk(image) + if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -308,16 +306,18 @@ def sort_tags_alphabetically(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - old_caption = self.tag_separator.join(image.tags) + + old_tags = image.tags.copy() if do_not_reorder_first_tag: first_tag = image.tags[0] image.tags = [first_tag] + sorted(image.tags[1:]) else: image.tags.sort() - new_caption = self.tag_separator.join(image.tags) - if new_caption != old_caption: + + if old_tags != image.tags: changed_image_indices.append(image_index) self.write_image_tags_to_disk(image) + if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -334,7 +334,8 @@ def sort_tags_by_frequency(self, tag_counter: Counter, for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - old_caption = self.tag_separator.join(image.tags) + + old_tags = image.tags.copy() if do_not_reorder_first_tag: first_tag = image.tags[0] image.tags = [first_tag] + sorted( @@ -342,10 +343,10 @@ def sort_tags_by_frequency(self, tag_counter: Counter, reverse=True) else: image.tags.sort(key=lambda tag: tag_counter[tag], reverse=True) - new_caption = self.tag_separator.join(image.tags) - if new_caption != old_caption: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + + if old_tags != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -358,12 +359,14 @@ def reverse_tags_order(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - changed_image_indices.append(image_index) + old_tags = image.tags.copy() if do_not_reorder_first_tag: image.tags = [image.tags[0]] + list(reversed(image.tags[1:])) else: image.tags = list(reversed(image.tags)) - self.write_image_tags_to_disk(image) + if old_tags != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -376,19 +379,21 @@ def shuffle_tags(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - changed_image_indices.append(image_index) + old_tags = image.tags.copy() if do_not_reorder_first_tag: first_tag, *remaining_tags = image.tags random.shuffle(remaining_tags) image.tags = [first_tag] + remaining_tags else: random.shuffle(image.tags) - self.write_image_tags_to_disk(image) + if old_tags != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) - def move_tags_to_front(self, tags_to_move: list[str]): + def move_tags_to_front(self, tags_to_move: List[str]): """ Move one or more tags to the front of the tags list for each image. """ @@ -398,17 +403,18 @@ def move_tags_to_front(self, tags_to_move: list[str]): for image_index, image in enumerate(self.images): if not any(tag in image.tags for tag in tags_to_move): continue - old_caption = self.tag_separator.join(image.tags) + + old_tags = image.tags.copy() moved_tags = [] for tag in tags_to_move: tag_count = image.tags.count(tag) moved_tags.extend([tag] * tag_count) unmoved_tags = [tag for tag in image.tags if tag not in moved_tags] image.tags = moved_tags + unmoved_tags - new_caption = self.tag_separator.join(image.tags) - if new_caption != old_caption: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + + if old_tags != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -429,7 +435,6 @@ def remove_duplicate_tags(self) -> int: continue changed_image_indices.append(image_index) removed_tag_count += tag_count - unique_tag_count - # Use a dictionary instead of a set to preserve the order. image.tags = list(dict.fromkeys(image.tags)) self.write_image_tags_to_disk(image) if changed_image_indices: @@ -460,32 +465,37 @@ def remove_empty_tags(self) -> int: self.index(changed_image_indices[-1])) return removed_tag_count - def update_image_tags(self, image_index: QModelIndex, tags: list[str]): + def update_image_tags(self, image_index: QModelIndex, tags: List[str]): image: Image = self.data(image_index, Qt.ItemDataRole.UserRole) - if image.tags == tags: - return - image.tags = tags - self.dataChanged.emit(image_index, image_index) - self.write_image_tags_to_disk(image) + if image and image.tags != tags: # Check if image is not None + image.tags = tags + self.dataChanged.emit(image_index, image_index) + self.write_image_tags_to_disk(image) + @Slot(list, list) - def add_tags(self, tags: list[str], image_indices: list[QModelIndex]): + def add_tags(self, tags: List[str], image_indices: List[QModelIndex]): """Add one or more tags to one or more images.""" if not image_indices: return action_name = f'Add {pluralize("Tag", len(tags))}' should_ask_for_confirmation = len(image_indices) > 1 self.add_to_undo_stack(action_name, should_ask_for_confirmation) + changed_image_indices = [] for image_index in image_indices: image: Image = self.data(image_index, Qt.ItemDataRole.UserRole) - image.tags.extend(tags) - self.write_image_tags_to_disk(image) - min_image_index = min(image_indices, key=lambda index: index.row()) - max_image_index = max(image_indices, key=lambda index: index.row()) - self.dataChanged.emit(min_image_index, max_image_index) + if image: + image.tags.extend(tags) + self.write_image_tags_to_disk(image) + changed_image_indices.append(image_index.row()) + if changed_image_indices: + min_image_index = min(changed_image_indices) + max_image_index = max(changed_image_indices) + self.dataChanged.emit(self.index(min_image_index), self.index(max_image_index)) - @Slot(list, str) - def rename_tags(self, old_tags: list[str], new_tag: str, + + @Slot(list, str, str, bool) + def rename_tags(self, old_tags: List[str], new_tag: str, scope: Scope | str = Scope.ALL_IMAGES, use_regex: bool = False): self.add_to_undo_stack( @@ -495,27 +505,26 @@ def rename_tags(self, old_tags: list[str], new_tag: str, for image_index, image in enumerate(self.images): if not self.is_image_in_scope(scope, image_index, image): continue + + old_tags_copy = image.tags.copy() if use_regex: pattern = old_tags[0] - if not any(re.fullmatch(pattern=pattern, string=image_tag) - for image_tag in image.tags): - continue image.tags = [new_tag if re.fullmatch(pattern=pattern, string=image_tag) else image_tag for image_tag in image.tags] else: - if not any(old_tag in image.tags for old_tag in old_tags): - continue image.tags = [new_tag if image_tag in old_tags else image_tag for image_tag in image.tags] - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + if old_tags_copy != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) + if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) - @Slot(list) - def delete_tags(self, tags: list[str], + @Slot(list, str, bool) + def delete_tags(self, tags: List[str], scope: Scope | str = Scope.ALL_IMAGES, use_regex: bool = False): self.add_to_undo_stack( @@ -525,21 +534,20 @@ def delete_tags(self, tags: list[str], for image_index, image in enumerate(self.images): if not self.is_image_in_scope(scope, image_index, image): continue + + old_tags_copy = image.tags.copy() if use_regex: pattern = tags[0] - if not any(re.fullmatch(pattern=pattern, string=image_tag) - for image_tag in image.tags): - continue image.tags = [image_tag for image_tag in image.tags if not re.fullmatch(pattern=pattern, string=image_tag)] else: - if not any(tag in image.tags for tag in tags): - continue image.tags = [image_tag for image_tag in image.tags if image_tag not in tags] - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + if old_tags_copy != image.tags: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) + if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), - self.index(changed_image_indices[-1])) + self.index(changed_image_indices[-1])) \ No newline at end of file From 7db736b359cd3a26c1ffda4ff1f5a4b4a6afacad Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Wed, 29 Jan 2025 07:38:21 -0500 Subject: [PATCH 03/11] a --- taggui/models/image_list_model.py | 48 ++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 737d7647..513c054b 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -12,13 +12,13 @@ Slot) from PySide6.QtGui import QIcon, QImageReader, QPixmap from PySide6.QtWidgets import QMessageBox +from PIL import Image as pilimage # Import Pillow's Image class + from utils.image import Image from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize -import pillow_jxl -from PIL import Image as pilimage - +#import pillow_jxl No need to import this as Pillow can load it UNDO_STACK_SIZE = 32 @@ -55,7 +55,7 @@ def __init__(self, image_list_image_width: int, tag_separator: str): self.tag_separator = tag_separator self.images: List[Image] = [] self.undo_stack = deque(maxlen=UNDO_STACK_SIZE) - self.redo_stack: List[HistoryItem] = [] # Type hint for clarity + self.redo_stack: List[HistoryItem] = [] # Type hint for clarity self.proxy_image_list_model = None self.image_list_selection_model = None @@ -76,14 +76,12 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N if role == Qt.ItemDataRole.DecorationRole: if image.thumbnail: return image.thumbnail - image_reader = QImageReader(str(image.path)) - image_reader.setAutoTransform(True) - pixmap = QPixmap.fromImageReader(image_reader).scaledToWidth( - self.image_list_image_width, - Qt.TransformationMode.SmoothTransformation) - thumbnail = QIcon(pixmap) - image.thumbnail = thumbnail - return thumbnail + try: + return self.get_icon(image, self.image_list_image_width) + except Exception as e: + print(f"Error getting icon for {image.path} {e}") + return QIcon() + if role == Qt.ItemDataRole.SizeHintRole: if image.thumbnail: return image.thumbnail.availableSizes()[0] @@ -96,6 +94,30 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N int(self.image_list_image_width * height / width)) return None # Added return None for clarity + def get_icon(self, image: Image, image_width: int) -> QIcon: + """Load the image and return a QIcon. Use PIL for JXL""" + try: + if image.path.suffix.lower() == ".jxl": + pil_image = pilimage.open(image.path) + qimage = pil_image.toqimage() + if qimage.width() > image_width or qimage.height() > image_width * 3: + qimage = pil_image.resize(QSize(image_width, image_width * 3)).toqimage() + pixmap = QPixmap.fromImage(qimage) + icon = QIcon(pixmap) + image.thumbnail = icon + return icon + else: + icon = QIcon(str(image.path)) + if (icon.availableSizes() and (icon.availableSizes()[0].width() + > image_width or icon.availableSizes()[0].height() + > image_width * 3)): + return QIcon(QPixmap(str(image.path)).scaled(image_width, image_width * 3)) + image.thumbnail = icon + return icon + except Exception as e: + print(f"Error loading image {image.path} {e}") + return QIcon() + def load_directory(self, directory_path: Path): self.beginResetModel() # Use begin/end reset for efficiency self.images.clear() @@ -174,7 +196,7 @@ def write_image_tags_to_disk(self, image: Image): errors='replace') except OSError: QMessageBox.critical(None, 'Error', - f'Failed to save tags for {image.path}.') # Simpler error message + f'Failed to save tags for {image.path}.') # Simpler error message def restore_history_tags(self, is_undo: bool): if is_undo: From 09c5d803ec1ac36609d3b1bfd123cef90ee17a4f Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Thu, 13 Feb 2025 09:52:04 -0500 Subject: [PATCH 04/11] jxl appears to be working now. --- taggui/models/image_list_model.py | 56 +++++++++++++++++------------ taggui/widgets/image_viewer.py | 59 +++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 33 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 513c054b..75eb16eb 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -10,17 +10,23 @@ import exifread from PySide6.QtCore import (QAbstractListModel, QModelIndex, QSize, Qt, Signal, Slot) -from PySide6.QtGui import QIcon, QImageReader, QPixmap +from PySide6.QtGui import QIcon, QImageReader, QPixmap, QImage from PySide6.QtWidgets import QMessageBox +import pillow_jxl from PIL import Image as pilimage # Import Pillow's Image class from utils.image import Image from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize -#import pillow_jxl No need to import this as Pillow can load it UNDO_STACK_SIZE = 32 +def pil_to_qimage(pil_image): + """Convert PIL image to QImage properly""" + pil_image = pil_image.convert("RGBA") + data = pil_image.tobytes("raw", "RGBA") + qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format_RGBA8888) + return qimage def get_file_paths(directory_path: Path) -> Set[Path]: """ @@ -94,30 +100,36 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N int(self.image_list_image_width * height / width)) return None # Added return None for clarity - def get_icon(self, image: Image, image_width: int) -> QIcon: - """Load the image and return a QIcon. Use PIL for JXL""" - try: - if image.path.suffix.lower() == ".jxl": - pil_image = pilimage.open(image.path) - qimage = pil_image.toqimage() - if qimage.width() > image_width or qimage.height() > image_width * 3: - qimage = pil_image.resize(QSize(image_width, image_width * 3)).toqimage() - pixmap = QPixmap.fromImage(qimage) - icon = QIcon(pixmap) - image.thumbnail = icon - return icon - else: + + def get_icon(self, image, image_width: int) -> QIcon: + """Load the image and return a QIcon while keeping aspect ratio.""" + try: + if image.path.suffix.lower() == ".jxl": + pil_image = pilimage.open(image.path) # Uses pillow-jxl + qimage = pil_to_qimage(pil_image) + + # Convert to QPixmap and scale while keeping aspect ratio + pixmap = QPixmap.fromImage(qimage) + pixmap = pixmap.scaled(image_width, image_width * 3, Qt.KeepAspectRatio, Qt.SmoothTransformation) + + icon = QIcon(pixmap) + image.thumbnail = icon + return icon + + else: icon = QIcon(str(image.path)) - if (icon.availableSizes() and (icon.availableSizes()[0].width() - > image_width or icon.availableSizes()[0].height() - > image_width * 3)): - return QIcon(QPixmap(str(image.path)).scaled(image_width, image_width * 3)) + if icon.availableSizes(): + size = icon.availableSizes()[0] + if size.width() > image_width or size.height() > image_width * 3: + pixmap = QPixmap(str(image.path)).scaled(image_width, image_width * 3, Qt.KeepAspectRatio, Qt.SmoothTransformation) + return QIcon(pixmap) + image.thumbnail = icon return icon - except Exception as e: - print(f"Error loading image {image.path} {e}") - return QIcon() + except Exception as e: + print(f"Error loading image {image.path}: {e}") + return QIcon() def load_directory(self, directory_path: Path): self.beginResetModel() # Use begin/end reset for efficiency self.images.clear() diff --git a/taggui/widgets/image_viewer.py b/taggui/widgets/image_viewer.py index 0b67d3cc..e63f2100 100644 --- a/taggui/widgets/image_viewer.py +++ b/taggui/widgets/image_viewer.py @@ -1,39 +1,76 @@ from pathlib import Path from PySide6.QtCore import QModelIndex, QSize, Qt, Slot -from PySide6.QtGui import QImageReader, QPixmap, QResizeEvent +from PySide6.QtGui import QImageReader, QPixmap, QResizeEvent, QImage from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget from models.proxy_image_list_model import ProxyImageListModel from utils.image import Image - +from PIL import Image as pilimage +import pillow_jxl # Ensure this is installed class ImageLabel(QLabel): def __init__(self): super().__init__() self.image_path = None self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setSizePolicy(QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding) - # This allows the label to shrink. - self.setMinimumSize(QSize(1, 1)) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setMinimumSize(QSize(1, 1)) # Allows the label to shrink - def resizeEvent(self, event: QResizeEvent): + def resizeEvent(self, event): """Reload the image whenever the label is resized.""" if self.image_path: self.load_image(self.image_path) def load_image(self, image_path: Path): self.image_path = image_path + + if image_path.suffix.lower() == ".jxl": + self.load_jxl_image(image_path) + else: + self.load_standard_image(image_path) + + def load_jxl_image(self, image_path: Path): + """Manually load a JPEG XL image and convert it to QPixmap.""" + try: + pil_image = pilimage.open(image_path) # Decode JXL using Pillow + pil_image = pil_image.convert("RGBA") # Ensure RGBA format + + qimage = QImage( + pil_image.tobytes("raw", "RGBA"), + pil_image.width, + pil_image.height, + QImage.Format_RGBA8888 + ) + + self.display_qimage(qimage) + + except Exception as e: + print(f"Error loading JXL image {image_path}: {e}") + self.clear() + + def load_standard_image(self, image_path: Path): + """Load standard images using QImageReader.""" image_reader = QImageReader(str(image_path)) - # Rotate the image according to the orientation tag. - image_reader.setAutoTransform(True) - pixmap = QPixmap.fromImageReader(image_reader) + image_reader.setAutoTransform(True) # Apply EXIF rotation + qimage = image_reader.read() + + if qimage.isNull(): + print(f"Error: QImageReader failed to load {image_path}") + self.clear() + return + + self.display_qimage(qimage) + + def display_qimage(self, qimage: QImage): + """Scale and display the QImage while maintaining aspect ratio.""" + pixmap = QPixmap.fromImage(qimage) pixmap.setDevicePixelRatio(self.devicePixelRatio()) pixmap = pixmap.scaled( self.size() * pixmap.devicePixelRatio(), Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation) + Qt.TransformationMode.SmoothTransformation + ) self.setPixmap(pixmap) From c94140b440cbb4d6c9ad18d66b7192d5d4325255 Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Thu, 13 Feb 2025 10:39:20 -0500 Subject: [PATCH 05/11] reverted some unrelated changes --- taggui/models/image_list_model.py | 60 +++++++++++++++++-------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 75eb16eb..9b364e0d 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -19,6 +19,7 @@ from utils.image import Image from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize + UNDO_STACK_SIZE = 32 def pil_to_qimage(pil_image): @@ -28,9 +29,10 @@ def pil_to_qimage(pil_image): qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format_RGBA8888) return qimage -def get_file_paths(directory_path: Path) -> Set[Path]: +def get_file_paths(directory_path: Path) -> set[Path]: """ - Recursively get all file paths in a directory, including subdirectories. + Recursively get all file paths in a directory, including + subdirectories. """ file_paths = set() for path in directory_path.rglob("*"): # Use rglob for recursive search @@ -42,7 +44,7 @@ def get_file_paths(directory_path: Path) -> Set[Path]: @dataclass class HistoryItem: action_name: str - tags: List[List[str]] + tags: list[list[str]] should_ask_for_confirmation: bool @@ -59,9 +61,9 @@ def __init__(self, image_list_image_width: int, tag_separator: str): super().__init__() self.image_list_image_width = image_list_image_width self.tag_separator = tag_separator - self.images: List[Image] = [] + self.images: list[Image] = [] self.undo_stack = deque(maxlen=UNDO_STACK_SIZE) - self.redo_stack: List[HistoryItem] = [] # Type hint for clarity + self.redo_stack = [] self.proxy_image_list_model = None self.image_list_selection_model = None @@ -75,6 +77,7 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N if role == Qt.ItemDataRole.UserRole: return image if role == Qt.ItemDataRole.DisplayRole: + # The text shown next to the thumbnail in the image list. text = image.path.name if image.tags: text += f'\n{self.tag_separator.join(image.tags)}' @@ -85,8 +88,8 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N try: return self.get_icon(image, self.image_list_image_width) except Exception as e: - print(f"Error getting icon for {image.path} {e}") - return QIcon() + print(f"Error getting icon for {image.path} {e}") + return QIcon() if role == Qt.ItemDataRole.SizeHintRole: if image.thumbnail: @@ -95,6 +98,7 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N if not dimensions: return QSize(self.image_list_image_width, self.image_list_image_width) + # Scale the dimensions to the image width. width, height = dimensions return QSize(self.image_list_image_width, int(self.image_list_image_width * height / width)) @@ -117,42 +121,46 @@ def get_icon(self, image, image_width: int) -> QIcon: return icon else: - icon = QIcon(str(image.path)) - if icon.availableSizes(): - size = icon.availableSizes()[0] - if size.width() > image_width or size.height() > image_width * 3: - pixmap = QPixmap(str(image.path)).scaled(image_width, image_width * 3, Qt.KeepAspectRatio, Qt.SmoothTransformation) - return QIcon(pixmap) - - image.thumbnail = icon - return icon + + image_reader = QImageReader(str(image.path)) + # Rotate the image based on the orientation tag. + image_reader.setAutoTransform(True) + pixmap = QPixmap.fromImageReader(image_reader).scaledToWidth( + self.image_list_image_width, + Qt.TransformationMode.SmoothTransformation) + thumbnail = QIcon(pixmap) + image.thumbnail = thumbnail + return thumbnail except Exception as e: print(f"Error loading image {image.path}: {e}") - return QIcon() + return QIcon() + + def load_directory(self, directory_path: Path): - self.beginResetModel() # Use begin/end reset for efficiency self.images.clear() self.undo_stack.clear() self.redo_stack.clear() self.update_undo_and_redo_actions_requested.emit() - file_paths = get_file_paths(directory_path) settings = get_settings() image_suffixes_string = settings.value( 'image_list_file_formats', defaultValue=DEFAULT_SETTINGS['image_list_file_formats'], type=str) - image_suffixes = [ - suffix.strip().lower() if suffix.startswith('.') else f'.{suffix.strip().lower()}' - for suffix in image_suffixes_string.split(',') - ] # Optimized list comprehension + image_suffixes = [] + for suffix in image_suffixes_string.split(','): + suffix = suffix.strip().lower() + if not suffix.startswith('.'): + suffix = '.' + suffix + image_suffixes.append(suffix) image_paths = {path for path in file_paths if path.suffix.lower() in image_suffixes} + # Comparing paths is slow on some systems, so convert the paths to + # strings. text_file_path_strings = {str(path) for path in file_paths if path.suffix == '.txt'} - for image_path in image_paths: try: with pilimage.open(image_path) as ci: @@ -190,8 +198,8 @@ def load_directory(self, directory_path: Path): self.images.append(image) self.images.sort(key=lambda image_: image_.path) - self.endResetModel() # Use begin/end reset for efficiency - + self.modelReset.emit() + def add_to_undo_stack(self, action_name: str, should_ask_for_confirmation: bool): """Add the current state of the image tags to the undo stack.""" From d47302aa6aeb529f85c2d1fc91f63652eef31d7f Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Thu, 13 Feb 2025 10:40:08 -0500 Subject: [PATCH 06/11] extra reversion --- taggui/models/image_list_model.py | 97 ++++++++++++++----------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 9b364e0d..10975009 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -272,14 +272,11 @@ def is_image_in_scope(self, scope: Scope | str, image_index: int, return True if scope == Scope.FILTERED_IMAGES: return self.proxy_image_list_model.is_image_in_filtered_images( - image) if self.proxy_image_list_model else False # Handle potential None + image) if scope == Scope.SELECTED_IMAGES: - if self.proxy_image_list_model and self.image_list_selection_model: #Handle potential None - proxy_index = self.proxy_image_list_model.mapFromSource( - self.index(image_index)) - return self.image_list_selection_model.isSelected(proxy_index) - return False - return False + proxy_index = self.proxy_image_list_model.mapFromSource( + self.index(image_index)) + return self.image_list_selection_model.isSelected(proxy_index) def get_text_match_count(self, text: str, scope: Scope | str, whole_tags_only: bool, use_regex: bool) -> int: @@ -290,10 +287,10 @@ def get_text_match_count(self, text: str, scope: Scope | str, continue if whole_tags_only: if use_regex: - match_count += sum( - 1 for tag in image.tags + match_count += len([ + tag for tag in image.tags if re.fullmatch(pattern=text, string=tag) - ) + ]) else: match_count += image.tags.count(text) else: @@ -330,11 +327,9 @@ def find_and_replace(self, find_text: str, replace_text: str, continue caption = caption.replace(find_text, replace_text) - new_tags = caption.split(self.tag_separator) - if new_tags != image.tags: - changed_image_indices.append(image_index) - image.tags = new_tags - self.write_image_tags_to_disk(image) + changed_image_indices.append(image_index) + image.tags = caption.split(self.tag_separator) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), @@ -377,7 +372,7 @@ def sort_tags_by_frequency(self, tag_counter: Counter, if len(image.tags) < 2: continue - old_tags = image.tags.copy() + changed_image_indices.append(image_index) if do_not_reorder_first_tag: first_tag = image.tags[0] image.tags = [first_tag] + sorted( @@ -386,9 +381,7 @@ def sort_tags_by_frequency(self, tag_counter: Counter, else: image.tags.sort(key=lambda tag: tag_counter[tag], reverse=True) - if old_tags != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -401,14 +394,12 @@ def reverse_tags_order(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - old_tags = image.tags.copy() + changed_image_indices.append(image_index) if do_not_reorder_first_tag: image.tags = [image.tags[0]] + list(reversed(image.tags[1:])) else: image.tags = list(reversed(image.tags)) - if old_tags != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -435,7 +426,7 @@ def shuffle_tags(self, do_not_reorder_first_tag: bool): self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) - def move_tags_to_front(self, tags_to_move: List[str]): + def move_tags_to_front(self, tags_to_move: list[str]): """ Move one or more tags to the front of the tags list for each image. """ @@ -507,37 +498,34 @@ def remove_empty_tags(self) -> int: self.index(changed_image_indices[-1])) return removed_tag_count - def update_image_tags(self, image_index: QModelIndex, tags: List[str]): + def update_image_tags(self, image_index: QModelIndex, tags: list[str]): image: Image = self.data(image_index, Qt.ItemDataRole.UserRole) - if image and image.tags != tags: # Check if image is not None - image.tags = tags - self.dataChanged.emit(image_index, image_index) - self.write_image_tags_to_disk(image) + if image.tags == tags: + return + image.tags = tags + self.dataChanged.emit(image_index, image_index) + self.write_image_tags_to_disk(image) @Slot(list, list) - def add_tags(self, tags: List[str], image_indices: List[QModelIndex]): + def add_tags(self, tags: list[str], image_indices: list[QModelIndex]): """Add one or more tags to one or more images.""" if not image_indices: return action_name = f'Add {pluralize("Tag", len(tags))}' should_ask_for_confirmation = len(image_indices) > 1 self.add_to_undo_stack(action_name, should_ask_for_confirmation) - changed_image_indices = [] for image_index in image_indices: image: Image = self.data(image_index, Qt.ItemDataRole.UserRole) - if image: - image.tags.extend(tags) - self.write_image_tags_to_disk(image) - changed_image_indices.append(image_index.row()) - if changed_image_indices: - min_image_index = min(changed_image_indices) - max_image_index = max(changed_image_indices) - self.dataChanged.emit(self.index(min_image_index), self.index(max_image_index)) + image.tags.extend(tags) + self.write_image_tags_to_disk(image) + min_image_index = min(image_indices, key=lambda index: index.row()) + max_image_index = max(image_indices, key=lambda index: index.row()) + self.dataChanged.emit(min_image_index, max_image_index) - @Slot(list, str, str, bool) - def rename_tags(self, old_tags: List[str], new_tag: str, + @Slot(list, str) + def rename_tags(self, old_tags: list[str], new_tag: str, scope: Scope | str = Scope.ALL_IMAGES, use_regex: bool = False): self.add_to_undo_stack( @@ -547,26 +535,28 @@ def rename_tags(self, old_tags: List[str], new_tag: str, for image_index, image in enumerate(self.images): if not self.is_image_in_scope(scope, image_index, image): continue - - old_tags_copy = image.tags.copy() if use_regex: pattern = old_tags[0] + if not any(re.fullmatch(pattern=pattern, string=image_tag) + for image_tag in image.tags): + continue image.tags = [new_tag if re.fullmatch(pattern=pattern, string=image_tag) else image_tag for image_tag in image.tags] else: + if not any(old_tag in image.tags for old_tag in old_tags): + continue image.tags = [new_tag if image_tag in old_tags else image_tag for image_tag in image.tags] - if old_tags_copy != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) - @Slot(list, str, bool) - def delete_tags(self, tags: List[str], + @Slot(list) + def delete_tags(self, tags: list[str], scope: Scope | str = Scope.ALL_IMAGES, use_regex: bool = False): self.add_to_undo_stack( @@ -577,18 +567,21 @@ def delete_tags(self, tags: List[str], if not self.is_image_in_scope(scope, image_index, image): continue - old_tags_copy = image.tags.copy() if use_regex: pattern = tags[0] + if not any(re.fullmatch(pattern=pattern, string=image_tag) + for image_tag in image.tags): + continue image.tags = [image_tag for image_tag in image.tags if not re.fullmatch(pattern=pattern, string=image_tag)] else: + if not any(tag in image.tags for tag in tags): + continue image.tags = [image_tag for image_tag in image.tags if image_tag not in tags] - if old_tags_copy != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), From 5165ea5a8fbbf049aa9443dab56a1f01f3b6dc5a Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Mon, 17 Mar 2025 07:53:35 -0400 Subject: [PATCH 07/11] used distys jpegxl util to get image sizes quicker. should speed up initial load by not loading entire jpeg image to get the size. --- taggui/models/image_list_model.py | 6 +- taggui/utils/jxlutil.py | 184 ++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 taggui/utils/jxlutil.py diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 10975009..599f9bad 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -17,6 +17,7 @@ from utils.image import Image +from taggui.utils.jxlutil import get_jxl_size from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize @@ -164,7 +165,10 @@ def load_directory(self, directory_path: Path): for image_path in image_paths: try: with pilimage.open(image_path) as ci: - dimensions = ci.size + if str(image_path).endswith('jxl'): + dimensions = get_jxl_size(image_path) + else: + dimensions = ci.size with open(image_path, 'rb') as image_file: try: exif_tags = exifread.process_file( diff --git a/taggui/utils/jxlutil.py b/taggui/utils/jxlutil.py new file mode 100644 index 00000000..b28c9a12 --- /dev/null +++ b/taggui/utils/jxlutil.py @@ -0,0 +1,184 @@ +# Modified from https://github.com/Fraetor/jxl_decode +# Added partial read support for up to 200x speedup +import os + +class JXLBitstream: + """ + A stream of bits with methods for easy handling. + """ + + def __init__(self, file, offset=0, offsets=[]) -> None: + self.shift = 0 + self.bitstream = [] + self.file = file + self.offset = offset + self.offsets = offsets + if self.offsets: + self.offset = self.offsets[0][1] + self.previous_data_len = 0 + self.index = 0 + self.file.seek(self.offset) + + def get_bits(self, length: int = 1) -> int: + if self.offsets and self.shift + length > self.previous_data_len + self.offsets[self.index][2]: + self.partial_to_read_length = length + if self.shift < self.previous_data_len + self.offsets[self.index][2]: + self.partial_read(0, length) + self.bitstream += self.file.read(self.partial_to_read_length) + else: + self.bitstream += self.file.read(length) + bitmask = 2**length - 1 + bits = (int.from_bytes(self.bitstream, "little") >> self.shift) & bitmask + self.shift += length + return bits + + def partial_read(self, current_length, length): + self.previous_data_len += self.offsets[self.index][2] + to_read_length = self.previous_data_len - (self.shift + current_length) + self.bitstream += self.file.read(to_read_length) + current_length += to_read_length + self.partial_to_read_length -= to_read_length + self.index += 1 + self.file.seek(self.offsets[self.index][1]) + if self.shift + length > self.previous_data_len + self.offsets[self.index][2]: + self.partial_read(current_length, length) + + +def decode_codestream(file, offset=0, offsets=[]): + """ + Decodes the actual codestream. + JXL codestream specification: http://www-internal/2022/18181-1 + """ + + # Convert codestream to int within an object to get some handy methods. + codestream = JXLBitstream(file, offset=offset, offsets=offsets) + + # Skip signature + codestream.get_bits(16) + + # SizeHeader + div8 = codestream.get_bits(1) + if div8: + height = 8 * (1 + codestream.get_bits(5)) + else: + distribution = codestream.get_bits(2) + match distribution: + case 0: + height = 1 + codestream.get_bits(9) + case 1: + height = 1 + codestream.get_bits(13) + case 2: + height = 1 + codestream.get_bits(18) + case 3: + height = 1 + codestream.get_bits(30) + ratio = codestream.get_bits(3) + if div8 and not ratio: + width = 8 * (1 + codestream.get_bits(5)) + elif not ratio: + distribution = codestream.get_bits(2) + match distribution: + case 0: + width = 1 + codestream.get_bits(9) + case 1: + width = 1 + codestream.get_bits(13) + case 2: + width = 1 + codestream.get_bits(18) + case 3: + width = 1 + codestream.get_bits(30) + else: + match ratio: + case 1: + width = height + case 2: + width = (height * 12) // 10 + case 3: + width = (height * 4) // 3 + case 4: + width = (height * 3) // 2 + case 5: + width = (height * 16) // 9 + case 6: + width = (height * 5) // 4 + case 7: + width = (height * 2) // 1 + return width, height + + +def decode_container(file): + """ + Parses the ISOBMFF container, extracts the codestream, and decodes it. + JXL container specification: http://www-internal/2022/18181-2 + """ + + def parse_box(file, file_start) -> dict: + file.seek(file_start) + LBox = int.from_bytes(file.read(4), "big") + XLBox = None + if 1 < LBox <= 8: + raise ValueError(f"Invalid LBox at byte {file_start}.") + if LBox == 1: + file.seek(file_start + 8) + XLBox = int.from_bytes(file.read(8), "big") + if XLBox <= 16: + raise ValueError(f"Invalid XLBox at byte {file_start}.") + if XLBox: + header_length = 16 + box_length = XLBox + else: + header_length = 8 + if LBox == 0: + box_length = os.fstat(file.fileno()).st_size - file_start + else: + box_length = LBox + file.seek(file_start + 4) + box_type = file.read(4) + file.seek(file_start) + return { + "length": box_length, + "type": box_type, + "offset": header_length, + } + + file.seek(0) + # Reject files missing required boxes. These two boxes are required to be at + # the start and contain no values, so we can manually check there presence. + # Signature box. (Redundant as has already been checked.) + if file.read(12) != bytes.fromhex("0000000C 4A584C20 0D0A870A"): + raise ValueError("Invalid signature box.") + # File Type box. + if file.read(20) != bytes.fromhex( + "00000014 66747970 6A786C20 00000000 6A786C20" + ): + raise ValueError("Invalid file type box.") + + offset = 0 + offsets = [] + data_offset_not_found = True + container_pointer = 32 + file_size = os.fstat(file.fileno()).st_size + while data_offset_not_found: + box = parse_box(file, container_pointer) + match box["type"]: + case b"jxlc": + offset = container_pointer + box["offset"] + data_offset_not_found = False + case b"jxlp": + file.seek(container_pointer + box["offset"]) + index = int.from_bytes(file.read(4), "big") + offsets.append([index, container_pointer + box["offset"] + 4, box["length"] - box["offset"] - 4]) + container_pointer += box["length"] + if container_pointer >= file_size: + data_offset_not_found = False + + if offsets: + offsets.sort(key=lambda i: i[0]) + file.seek(0) + + return decode_codestream(file, offset=offset, offsets=offsets) + + +def get_jxl_size(path): + with open(path, "rb") as file: + if file.read(2) == bytes.fromhex("FF0A"): + return decode_codestream(file) + return decode_container(file) \ No newline at end of file From 660c96a0a6129d94003a4545a66982cca93311c6 Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Mon, 17 Mar 2025 08:44:48 -0400 Subject: [PATCH 08/11] actually tested this time. also changed something to potentially load other formats slightly faster. --- taggui/models/image_list_model.py | 39 +++++++++++++++---------------- taggui/utils/jxlutil.py | 2 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 599f9bad..e6b03f99 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -17,7 +17,7 @@ from utils.image import Image -from taggui.utils.jxlutil import get_jxl_size +from utils.jxlutil import get_jxl_size from utils.settings import DEFAULT_SETTINGS, get_settings from utils.utils import get_confirmation_dialog_reply, pluralize @@ -164,25 +164,24 @@ def load_directory(self, directory_path: Path): if path.suffix == '.txt'} for image_path in image_paths: try: - with pilimage.open(image_path) as ci: - if str(image_path).endswith('jxl'): - dimensions = get_jxl_size(image_path) - else: - dimensions = ci.size - with open(image_path, 'rb') as image_file: - try: - exif_tags = exifread.process_file( - image_file, details=False, - stop_tag='Image Orientation') - if 'Image Orientation' in exif_tags: - orientations = (exif_tags['Image Orientation'] - .values) - if any(value in orientations - for value in (5, 6, 7, 8)): - dimensions = (dimensions[1], dimensions[0]) - except Exception as exception: - print(f'Failed to get Exif tags for {image_path}: ' - f'{exception}', file=sys.stderr) + if str(image_path).endswith('jxl'): + dimensions = get_jxl_size(image_path) + else: + dimensions = pilimage.open(image_path).size + with open(image_path, 'rb') as image_file: + try: + exif_tags = exifread.process_file( + image_file, details=False, + stop_tag='Image Orientation') + if 'Image Orientation' in exif_tags: + orientations = (exif_tags['Image Orientation'] + .values) + if any(value in orientations + for value in (5, 6, 7, 8)): + dimensions = (dimensions[1], dimensions[0]) + except Exception as exception: + print(f'Failed to get Exif tags for {image_path}: ' + f'{exception}', file=sys.stderr) except (ValueError, OSError) as exception: print(f'Failed to get dimensions for {image_path}: ' f'{exception}', file=sys.stderr) diff --git a/taggui/utils/jxlutil.py b/taggui/utils/jxlutil.py index b28c9a12..5ea4c981 100644 --- a/taggui/utils/jxlutil.py +++ b/taggui/utils/jxlutil.py @@ -47,7 +47,7 @@ def partial_read(self, current_length, length): def decode_codestream(file, offset=0, offsets=[]): """ Decodes the actual codestream. - JXL codestream specification: http://www-internal/2022/18181-1 + JXL codestream specification: https://www.iso.org/standard/85066.html """ # Convert codestream to int within an object to get some handy methods. From 1add471f0574cdd14b3ade356dec1187127c19ef Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Mon, 17 Mar 2025 09:23:45 -0400 Subject: [PATCH 09/11] purged unrelated changes --- taggui/models/image_list_model.py | 84 +++++++++++++++---------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index e6b03f99..537fdc34 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -81,9 +81,12 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N # The text shown next to the thumbnail in the image list. text = image.path.name if image.tags: - text += f'\n{self.tag_separator.join(image.tags)}' + caption = self.tag_separator.join(image.tags) + text += f'\n{caption}' return text if role == Qt.ItemDataRole.DecorationRole: + # The thumbnail. If the image already has a thumbnail stored, use + # it. Otherwise, generate a thumbnail and save it to the image. if image.thumbnail: return image.thumbnail try: @@ -99,8 +102,8 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N if not dimensions: return QSize(self.image_list_image_width, self.image_list_image_width) - # Scale the dimensions to the image width. width, height = dimensions + # Scale the dimensions to the image width. return QSize(self.image_list_image_width, int(self.image_list_image_width * height / width)) return None # Added return None for clarity @@ -148,14 +151,12 @@ def load_directory(self, directory_path: Path): image_suffixes_string = settings.value( 'image_list_file_formats', defaultValue=DEFAULT_SETTINGS['image_list_file_formats'], type=str) - image_suffixes = [] for suffix in image_suffixes_string.split(','): suffix = suffix.strip().lower() if not suffix.startswith('.'): suffix = '.' + suffix image_suffixes.append(suffix) - image_paths = {path for path in file_paths if path.suffix.lower() in image_suffixes} # Comparing paths is slow on some systems, so convert the paths to @@ -190,19 +191,20 @@ def load_directory(self, directory_path: Path): tags = [] text_file_path = image_path.with_suffix('.txt') if str(text_file_path) in text_file_path_strings: - try: - caption = text_file_path.read_text(encoding='utf-8', errors='replace') - if caption: - tags = [tag.strip() for tag in caption.split(self.tag_separator) if tag.strip()] # Optimized tag creation - except Exception as exception: - print(f'Failed to read caption for {text_file_path}: {exception}', file=sys.stderr) - + # `errors='replace'` inserts a replacement marker such as '?' + # when there is malformed data. + caption = text_file_path.read_text(encoding='utf-8', + errors='replace') + if caption: + tags = caption.split(self.tag_separator) + tags = [tag.strip() for tag in tags] + tags = [tag for tag in tags if tag] image = Image(image_path, dimensions, tags) self.images.append(image) self.images.sort(key=lambda image_: image_.path) self.modelReset.emit() - + def add_to_undo_stack(self, action_name: str, should_ask_for_confirmation: bool): """Add the current state of the image tags to the undo stack.""" @@ -226,13 +228,12 @@ def restore_history_tags(self, is_undo: bool): source_stack = self.undo_stack destination_stack = self.redo_stack else: + # Redo. source_stack = self.redo_stack destination_stack = self.undo_stack - if not source_stack: return - - history_item = source_stack.pop() + history_item = source_stack[-1] if history_item.should_ask_for_confirmation: undo_or_redo_string = 'Undo' if is_undo else 'Redo' reply = get_confirmation_dialog_reply( @@ -241,18 +242,20 @@ def restore_history_tags(self, is_undo: bool): f'"{history_item.action_name}"?') if reply != QMessageBox.StandardButton.Yes: return - + source_stack.pop() + tags = [image.tags for image in self.images] destination_stack.append(HistoryItem( - history_item.action_name, [image.tags for image in self.images], # Optimized tag capture + history_item.action_name, tags, history_item.should_ask_for_confirmation)) changed_image_indices = [] for image_index, (image, history_image_tags) in enumerate( zip(self.images, history_item.tags)): - if image.tags != history_image_tags: - changed_image_indices.append(image_index) - image.tags = history_image_tags - self.write_image_tags_to_disk(image) + if image.tags == history_image_tags: + continue + changed_image_indices.append(image_index) + image.tags = history_image_tags + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), @@ -329,11 +332,9 @@ def find_and_replace(self, find_text: str, replace_text: str, if find_text not in caption: continue caption = caption.replace(find_text, replace_text) - changed_image_indices.append(image_index) image.tags = caption.split(self.tag_separator) self.write_image_tags_to_disk(image) - if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -346,18 +347,16 @@ def sort_tags_alphabetically(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - - old_tags = image.tags.copy() + old_caption = self.tag_separator.join(image.tags) if do_not_reorder_first_tag: first_tag = image.tags[0] image.tags = [first_tag] + sorted(image.tags[1:]) else: image.tags.sort() - - if old_tags != image.tags: + new_caption = self.tag_separator.join(image.tags) + if new_caption != old_caption: changed_image_indices.append(image_index) self.write_image_tags_to_disk(image) - if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -374,8 +373,7 @@ def sort_tags_by_frequency(self, tag_counter: Counter, for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - - changed_image_indices.append(image_index) + old_caption = self.tag_separator.join(image.tags) if do_not_reorder_first_tag: first_tag = image.tags[0] image.tags = [first_tag] + sorted( @@ -383,8 +381,10 @@ def sort_tags_by_frequency(self, tag_counter: Counter, reverse=True) else: image.tags.sort(key=lambda tag: tag_counter[tag], reverse=True) - - self.write_image_tags_to_disk(image) + new_caption = self.tag_separator.join(image.tags) + if new_caption != old_caption: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -415,16 +415,14 @@ def shuffle_tags(self, do_not_reorder_first_tag: bool): for image_index, image in enumerate(self.images): if len(image.tags) < 2: continue - old_tags = image.tags.copy() + changed_image_indices.append(image_index) if do_not_reorder_first_tag: first_tag, *remaining_tags = image.tags random.shuffle(remaining_tags) image.tags = [first_tag] + remaining_tags else: random.shuffle(image.tags) - if old_tags != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -439,18 +437,17 @@ def move_tags_to_front(self, tags_to_move: list[str]): for image_index, image in enumerate(self.images): if not any(tag in image.tags for tag in tags_to_move): continue - - old_tags = image.tags.copy() + old_caption = self.tag_separator.join(image.tags) moved_tags = [] for tag in tags_to_move: tag_count = image.tags.count(tag) moved_tags.extend([tag] * tag_count) unmoved_tags = [tag for tag in image.tags if tag not in moved_tags] image.tags = moved_tags + unmoved_tags - - if old_tags != image.tags: - changed_image_indices.append(image_index) - self.write_image_tags_to_disk(image) + new_caption = self.tag_separator.join(image.tags) + if new_caption != old_caption: + changed_image_indices.append(image_index) + self.write_image_tags_to_disk(image) if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), self.index(changed_image_indices[-1])) @@ -585,7 +582,6 @@ def delete_tags(self, tags: list[str], if image_tag not in tags] changed_image_indices.append(image_index) self.write_image_tags_to_disk(image) - if changed_image_indices: self.dataChanged.emit(self.index(changed_image_indices[0]), - self.index(changed_image_indices[-1])) \ No newline at end of file + self.index(changed_image_indices[-1])) From 7c4adaeaa77db235dde0c65c8d433e0955e6c05d Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Mon, 17 Mar 2025 09:50:45 -0400 Subject: [PATCH 10/11] adding a none type was causing error messages. fixed that. --- taggui/models/image_list_model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/taggui/models/image_list_model.py b/taggui/models/image_list_model.py index 537fdc34..497de4b1 100644 --- a/taggui/models/image_list_model.py +++ b/taggui/models/image_list_model.py @@ -71,9 +71,7 @@ def __init__(self, image_list_image_width: int, tag_separator: str): def rowCount(self, parent=None) -> int: return len(self.images) - def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | None: # Added None to possible return type - if not index.isValid() or index.row() >= len(self.images): #Handle invalid index - return None + def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize: image = self.images[index.row()] if role == Qt.ItemDataRole.UserRole: return image @@ -106,7 +104,6 @@ def data(self, index: QModelIndex, role=None) -> Image | str | QIcon | QSize | N # Scale the dimensions to the image width. return QSize(self.image_list_image_width, int(self.image_list_image_width * height / width)) - return None # Added return None for clarity def get_icon(self, image, image_width: int) -> QIcon: From 7f2167735537ceaa7020ae1bf7c1060b2251373a Mon Sep 17 00:00:00 2001 From: Yggdrasil75 Date: Mon, 17 Mar 2025 09:57:50 -0400 Subject: [PATCH 11/11] match upstream requirements.txt --- requirements.txt | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/requirements.txt b/requirements.txt index 27a89ad1..84d577eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,40 +2,36 @@ accelerate==1.1.0 bitsandbytes==0.45.0 ExifRead==3.0.0 imagesize==1.4.1 -pillow==11.0.0 +pillow==11.1.0 pillow-jxl-plugin~=1.3.1 -pyparsing==3.2.0 -PySide6==6.8.1 -transformers==4.45.2 - -# PyTorch -# AutoGPTQ does not support PyTorch v2.3. -torch==2.2.2; platform_system != "Windows" -https://download.pytorch.org/whl/cu121/torch-2.2.2%2Bcu121-cp311-cp311-win_amd64.whl; platform_system == "Windows" and python_version == "3.11" -https://download.pytorch.org/whl/cu121/torch-2.2.2%2Bcu121-cp310-cp310-win_amd64.whl; platform_system == "Windows" and python_version == "3.10" +pyparsing==3.2.1 +PySide6==6.8.2.1 +transformers==4.48.3 # CogAgent -timm==1.0.12 +timm==1.0.14 # CogVLM -einops==0.8.0 -protobuf==5.29.1 +einops==0.8.1 +protobuf==5.29.3 sentencepiece==0.2.0 -# These versions of torchvision and xFormers are the latest versions compatible -# with PyTorch v2.2.2. -torchvision==0.17.2 -xformers==0.0.25.post1 +torchvision==0.21.0 +xformers==0.0.29.post3 # InternLM-XComposer2 -auto-gptq==0.7.1; platform_system == "Linux" or platform_system == "Windows" -# PyTorch versions prior to 2.3 do not support NumPy v2. -numpy==1.26.4 +gptqmodel==1.9.0 +numpy==2.2.3 # WD Tagger -huggingface-hub==0.26.5 +huggingface-hub==0.29.1 onnxruntime==1.20.1 +# PyTorch +torch==2.6.0; platform_system != "Windows" +https://download.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp312-cp312-win_amd64.whl; platform_system == "Windows" and python_version == "3.12" +https://download.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp311-cp311-win_amd64.whl; platform_system == "Windows" and python_version == "3.11" + # FlashAttention (Florence-2, Phi-3-Vision) -flash-attn==2.6.3; platform_system == "Linux" -https://github.com/bdashore3/flash-attention/releases/download/v2.6.3/flash_attn-2.6.3+cu123torch2.2.2cxx11abiFALSE-cp311-cp311-win_amd64.whl; platform_system == "Windows" and python_version == "3.11" -https://github.com/bdashore3/flash-attention/releases/download/v2.6.3/flash_attn-2.6.3+cu123torch2.2.2cxx11abiFALSE-cp310-cp310-win_amd64.whl; platform_system == "Windows" and python_version == "3.10" +flash-attn==2.7.4.post1; platform_system == "Linux" +https://github.com/kingbri1/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu124torch2.6.0cxx11abiFALSE-cp312-cp312-win_amd64.whl; platform_system == "Windows" and python_version == "3.12" +https://github.com/kingbri1/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu124torch2.6.0cxx11abiFALSE-cp311-cp311-win_amd64.whl; platform_system == "Windows" and python_version == "3.11" \ No newline at end of file