diff --git a/modules/ui/BaseConceptWindowView.py b/modules/ui/BaseConceptWindowView.py index 0b94e50d1..68831c497 100644 --- a/modules/ui/BaseConceptWindowView.py +++ b/modules/ui/BaseConceptWindowView.py @@ -12,6 +12,8 @@ def __init__(self, components): self.bucket_ax = None self.text_color = None self.canvas = None + #{aspect_ratio_string: [relative file paths]} for the currently displayed smallest buckets + self._smallest_bucket_files = {} def build_general_tab(self, frame, controller, ui_state, text_ui_state): # name @@ -305,8 +307,10 @@ def build_concept_stats_tab(self, frame, controller): tooltip="Graph of all possible buckets and the number of images in each one, defined as height/width. Buckets range from 0.25 (4:1 extremely wide) to 4 (1:4 extremely tall). \ Images which don't match a bucket exactly are cropped to the nearest one.", underline=True) self.small_bucket_label = self.components.label(frame, 17, 1, "\nSmallest Buckets", pad=0, - tooltip="Image buckets with the least nonzero total images - if 'batch size' is larger than this, these images will be ignored during training! See the wiki for more details.", underline=True) + tooltip="Image buckets with the least nonzero total images - if 'batch size' is larger than this, these images will be ignored during training! See the wiki for more details.\n\nClick the list below to see the files in these buckets.", underline=True) self.small_bucket_preview = self.components.label(frame, 18, 1, pad=0, text="-") + #clicking the preview opens a popup listing the files in the smallest buckets; populated by _update_concept_stats + self.components.bind_clickable(self.small_bucket_preview, self._show_smallest_bucket_files) #refresh stats - must be after all labels are defined or will give error self.refresh_basic_stats_button = self.components.button(master=frame, row=0, column=0, text="Refresh Basic", command=lambda: controller.get_concept_stats_threaded(self, False, 9999), @@ -403,6 +407,8 @@ def _update_concept_stats(self, controller): #aspect bucketing aspect_buckets = controller.concept.concept_stats["aspect_buckets"] + aspect_bucket_files = controller.concept.concept_stats["aspect_bucket_files"] + self._smallest_bucket_files = {} if len(aspect_buckets) != 0 and max(val for val in aspect_buckets.values()) > 0: #check aspect_bucket data exists and is not all zero min_val = min(val for val in aspect_buckets.values() if val > 0) #smallest nonzero values if max(val for val in aspect_buckets.values()) > min_val: #check if any buckets larger than min_val exist - if all images are same aspect then there won't be @@ -412,7 +418,10 @@ def _update_concept_stats(self, controller): min_aspect_buckets = {key: val for key,val in aspect_buckets.items() if val in (min_val, min_val2)} min_bucket_str = "" for key, val in min_aspect_buckets.items(): - min_bucket_str += f'aspect {self.decimal_to_aspect_ratio(key)} : {val} img\n' + aspect_str = self.decimal_to_aspect_ratio(key) + min_bucket_str += f'aspect {aspect_str} : {val} img\n' + #remember the files behind each displayed bucket so the click handler can list them + self._smallest_bucket_files[aspect_str] = aspect_bucket_files.get(key, []) min_bucket_str.strip() self.components.set_label_text(self.small_bucket_preview, min_bucket_str) @@ -434,6 +443,17 @@ def decimal_to_aspect_ratio(self, value : float): aspect_string = f'{aspect_fraction.denominator}:{aspect_fraction.numerator}' return aspect_string + def _show_smallest_bucket_files(self): + #popup listing the files in each of the smallest buckets, grouped by aspect ratio + if not any(self._smallest_bucket_files.values()): + return #no advanced scan data yet + sections = [] + for aspect_str, files in self._smallest_bucket_files.items(): + header = f'aspect {aspect_str} ({len(files)} img):' + sections.append(header + "\n" + "\n".join(files)) + text = "\n\n".join(sections) + self.components.show_text_popup(self, "Smallest bucket files", text) + def _disable_scan_buttons(self): self.components.set_widget_enabled(self.refresh_basic_stats_button, False) self.components.set_widget_enabled(self.refresh_advanced_stats_button, False) diff --git a/modules/util/concept_stats.py b/modules/util/concept_stats.py index 9e0f31cef..f10e7ace6 100644 --- a/modules/util/concept_stats.py +++ b/modules/util/concept_stats.py @@ -44,6 +44,7 @@ def init_concept_stats(advanced_checks : bool): "min_caption_length" : "-", "avg_caption_length" : "-", "aspect_buckets" : {}, + "aspect_bucket_files" : {}, #relative file paths per bucket, populated only in advanced scan "force_cancelled" : False } @@ -80,6 +81,7 @@ def init_concept_stats(advanced_checks : bool): #initialize counts for all buckets to 0 for aspect in aspect_ratio_list: stats_dict["aspect_buckets"][aspect] = 0 + stats_dict["aspect_bucket_files"][aspect] = [] return stats_dict @@ -140,6 +142,7 @@ def folder_scan(dir, stats_dict : dict, advanced_checks : bool, conceptconfig : true_aspect = height/width nearest_aspect = min(aspect_ratio_list, key=lambda x:abs(x-true_aspect)) #try to match math used in aspect bucketing stats_dict["aspect_buckets"][nearest_aspect] += 1 + stats_dict["aspect_bucket_files"][nearest_aspect].append(os.path.relpath(path, conceptconfig.path)) if pixels > stats_dict["max_pixels"][0]: stats_dict["max_pixels"] = [pixels, os.path.relpath(path, conceptconfig.path), f'{width}w x {height}h'] @@ -183,6 +186,7 @@ def folder_scan(dir, stats_dict : dict, advanced_checks : bool, conceptconfig : true_aspect = height/width nearest_aspect = min(aspect_ratio_list, key=lambda x:abs(x-true_aspect)) stats_dict["aspect_buckets"][nearest_aspect] += 1 + stats_dict["aspect_bucket_files"][nearest_aspect].append(os.path.relpath(path, conceptconfig.path)) if pixels > stats_dict["max_pixels"][0]: stats_dict["max_pixels"] = [pixels, os.path.relpath(path, conceptconfig.path), f'{width}w x {height}h'] @@ -238,6 +242,9 @@ def combine_stats_dicts(input_dicts : list[dict], advanced_checks : bool): elif advanced_checks and key in ["aspect_buckets"]: for subkey in dict[key]: final_dict[key][subkey] += dict[key][subkey] + elif advanced_checks and key in ["aspect_bucket_files"]: + for subkey in dict[key]: + final_dict[key][subkey].extend(dict[key][subkey]) elif advanced_checks and key in ["max_pixels", "max_length", "max_fps", "max_caption_length"]: if dict[key][0] > final_dict[key][0]: final_dict[key] = dict[key] diff --git a/modules/util/ui/ctk_components.py b/modules/util/ui/ctk_components.py index 4f1d28b65..5dff5c20e 100644 --- a/modules/util/ui/ctk_components.py +++ b/modules/util/ui/ctk_components.py @@ -618,3 +618,30 @@ def set_label_text(label, text: str) -> None: def call_after(widget, delay_ms: int, func) -> None: widget.after(delay_ms, func) + + +def bind_clickable(widget, command: Callable[[], None]) -> None: + widget.configure(cursor="hand2") + widget.bind("", lambda _event: command()) + + +def show_text_popup(parent, title: str, text: str) -> None: + from modules.util.ui.ui_utils import set_window_icon + + window = ctk.CTkToplevel(parent) + window.title(title) + window.geometry("500x500") + window.grid_rowconfigure(0, weight=1) + window.grid_columnconfigure(0, weight=1) + + textbox = ctk.CTkTextbox(window, wrap="none") + textbox.grid(row=0, column=0, sticky="nsew", padx=PAD, pady=PAD) + textbox.insert("1.0", text) + textbox.configure(state="disabled") + + ctk.CTkButton(window, text="ok", command=window.destroy).grid(row=1, column=0, padx=PAD, pady=PAD) + + window.wait_visibility() + window.grab_set() + window.focus_set() + window.after(200, lambda: set_window_icon(window)) diff --git a/modules/util/ui/pyside6_components.py b/modules/util/ui/pyside6_components.py index 5b1a18598..bf1bd70ae 100644 --- a/modules/util/ui/pyside6_components.py +++ b/modules/util/ui/pyside6_components.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, + QDialog, QFileDialog, QFrame, QGridLayout, @@ -25,6 +26,7 @@ QPushButton, QScrollArea, QSizePolicy, + QTextEdit, QToolButton, QVBoxLayout, QWidget, @@ -810,3 +812,28 @@ def set_label_text(label: QLabel, text: str) -> None: def call_after(widget: QWidget, delay_ms: int, func) -> None: QTimer.singleShot(delay_ms, widget, func) + + +def bind_clickable(widget: QWidget, command: Callable[[], None]) -> None: + widget.setCursor(Qt.PointingHandCursor) + # QLabel has no clicked signal; route the raw press to the callback + widget.mousePressEvent = lambda _event: command() + + +def show_text_popup(parent: QWidget, title: str, text: str) -> None: + dialog = QDialog(parent) + dialog.setWindowTitle(title) + dialog.resize(500, 500) + + lo = QVBoxLayout(dialog) + box = QTextEdit(dialog) + box.setReadOnly(True) + box.setLineWrapMode(QTextEdit.NoWrap) + box.setPlainText(text) + lo.addWidget(box) + + ok = QPushButton("ok", dialog) + ok.clicked.connect(dialog.accept) + lo.addWidget(ok) + + dialog.exec()