diff --git a/app/lang/de.toml b/app/lang/de.toml index a67115d..de7c127 100644 --- a/app/lang/de.toml +++ b/app/lang/de.toml @@ -10,6 +10,8 @@ "toolbar.undo_exclude" = "Letzten Ausschluss entfernen" "toolbar.reset_sliders" = "Slider zurücksetzen" "toolbar.toggle_theme" = "Theme umschalten" +"toolbar.open_app_folder" = "Programmordner öffnen" +"toolbar.prefer_dark" = "Dunkelheit bevorzugen" "status.no_file" = "Keine Datei geladen." "status.defaults_restored" = "Standardwerte aktiv." "status.free_draw_enabled" = "Freihand-Ausschluss aktiviert." @@ -41,7 +43,9 @@ "sliders.val_max" = "Helligkeit Max (%)" "sliders.alpha" = "Overlay Alpha" "stats.placeholder" = "Markierungen (mit Ausschlüssen): —" -"stats.summary" = "Markierungen (mit Ausschlüssen): {with_pct:.2f}% | Markierungen (ohne Ausschlüsse): {without_pct:.2f}% | Ausgeschlossen: {excluded_pct:.2f}% der Pixel, davon {excluded_match_pct:.2f}% markiert" +"stats.summary" = "Score: {score:.2f}% | Markierungen (m. Ausschl.): {with_pct:.2f}% | Markierungen: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Ausgeschlossen: {excluded_pct:.2f}%" +"stats.brightness_label" = "Helligkeit" +"stats.darkness_label" = "Dunkelheit" "menu.copy" = "Kopieren" "dialog.info_title" = "Info" "dialog.error_title" = "Fehler" diff --git a/app/lang/en.toml b/app/lang/en.toml index bb9b5be..c0183d6 100644 --- a/app/lang/en.toml +++ b/app/lang/en.toml @@ -10,6 +10,8 @@ "toolbar.undo_exclude" = "Undo last exclusion" "toolbar.reset_sliders" = "Reset sliders" "toolbar.toggle_theme" = "Toggle theme" +"toolbar.open_app_folder" = "Open application folder" +"toolbar.prefer_dark" = "Prefer darkness" "status.no_file" = "No file loaded." "status.defaults_restored" = "Defaults restored." "status.free_draw_enabled" = "Free-draw exclusion mode enabled." @@ -41,7 +43,9 @@ "sliders.val_max" = "Value max (%)" "sliders.alpha" = "Overlay alpha" "stats.placeholder" = "Matches (with exclusions): —" -"stats.summary" = "Matches (with exclusions): {with_pct:.2f}% | Matches (without exclusions): {without_pct:.2f}% | Excluded: {excluded_pct:.2f}% of pixels, {excluded_match_pct:.2f}% marked" +"stats.summary" = "Score: {score:.2f}% | Matches (w/ excl.): {with_pct:.2f}% | Matches: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Excluded: {excluded_pct:.2f}%" +"stats.brightness_label" = "Brightness" +"stats.darkness_label" = "Darkness" "menu.copy" = "Copy" "dialog.info_title" = "Info" "dialog.error_title" = "Error" diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index a6d1d0f..5d1f6f5 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -22,6 +22,20 @@ class Stats: total_keep: int = 0 matches_excl: int = 0 total_excl: int = 0 + brightness_score: float = 0.0 + prefer_dark: bool = False + + @property + def effective_brightness(self) -> float: + """Returns inverted brightness when prefer_dark is on.""" + return (100.0 - self.brightness_score) if self.prefer_dark else self.brightness_score + + @property + def composite_score(self) -> float: + """Weighted composite: 35% match_all + 55% match_keep + 10% brightness.""" + pct_all = (self.matches_all / self.total_all * 100) if self.total_all else 0.0 + pct_keep = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0 + return 0.35 * pct_all + 0.55 * pct_keep + 0.10 * self.effective_brightness def summary(self, translate) -> str: if self.total_all == 0: @@ -29,13 +43,15 @@ class Stats: with_pct = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0 without_pct = (self.matches_all / self.total_all * 100) if self.total_all else 0.0 excluded_pct = (self.total_excl / self.total_all * 100) if self.total_all else 0.0 - excluded_match_pct = (self.matches_excl / self.total_excl * 100) if self.total_excl else 0.0 + brightness_label = translate("stats.darkness_label") if self.prefer_dark else translate("stats.brightness_label") return translate( "stats.summary", + score=self.composite_score, with_pct=with_pct, without_pct=without_pct, + brightness_label=brightness_label, + brightness=self.effective_brightness, excluded_pct=excluded_pct, - excluded_match_pct=excluded_match_pct, ) @@ -108,6 +124,7 @@ class QtImageProcessor: self._cached_mask: np.ndarray | None = None self._cached_mask_size: Tuple[int, int] | None = None self.exclude_ref_size: Tuple[int, int] | None = None + self.prefer_dark: bool = False def set_defaults(self, defaults: dict) -> None: for key in self.defaults: @@ -221,6 +238,10 @@ class QtImageProcessor: matches_excl = int(excl_match[visible].sum()) total_excl = int((visible & excl_mask).sum()) + # Brightness: mean Value (0-100) of ALL non-excluded visible pixels + keep_visible = visible & ~excl_mask + brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0 + # Build overlay image overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8) overlay_arr[keep_match, 0] = self.overlay_r @@ -236,6 +257,8 @@ class QtImageProcessor: total_keep=total_keep, matches_excl=matches_excl, total_excl=total_excl, + brightness_score=brightness, + prefer_dark=self.prefer_dark, ) def get_stats_headless(self, image: Image.Image) -> Stats: @@ -273,13 +296,19 @@ class QtImageProcessor: excl_match = match_mask & excl_mask visible = alpha_ch > 0 + matches_keep_count = int(keep_match[visible].sum()) + keep_visible = visible & ~excl_mask + brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0 + return Stats( matches_all=int(match_mask[visible].sum()), total_all=int(visible.sum()), - matches_keep=int(keep_match[visible].sum()), + matches_keep=matches_keep_count, total_keep=int((visible & ~excl_mask).sum()), matches_excl=int(excl_match[visible].sum()), total_excl=int((visible & excl_mask).sum()), + brightness_score=brightness, + prefer_dark=self.prefer_dark, ) # helpers ---------------------------------------------------------------- diff --git a/app/qt/main_window.py b/app/qt/main_window.py index 7c0e6b4..bfcfd36 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -2,6 +2,7 @@ from __future__ import annotations import re +import os import time import urllib.request import urllib.error @@ -637,13 +638,24 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): tools_menu = self.menu_bar.addMenu(self._t("menu.tools")) tools_menu.addAction("🎨 " + self._t("toolbar.choose_color"), lambda: self._invoke_action("choose_color")) tools_menu.addAction("🖱 " + self._t("toolbar.pick_from_image"), lambda: self._invoke_action("pick_from_image")) - tools_menu.addAction("△ " + self._t("toolbar.toggle_free_draw"), lambda: self._invoke_action("toggle_free_draw")) + self.free_draw_action = QtGui.QAction("△ " + self._t("toolbar.toggle_free_draw"), self) + self.free_draw_action.setCheckable(True) + self.free_draw_action.setChecked(False) + self.free_draw_action.triggered.connect(lambda: self._invoke_action("toggle_free_draw")) + tools_menu.addAction(self.free_draw_action) tools_menu.addSeparator() tools_menu.addAction("📥 " + self._t("toolbar.pull_patterns"), lambda: self._invoke_action("pull_patterns")) # View Menu view_menu = self.menu_bar.addMenu(self._t("menu.view")) view_menu.addAction("🌓 " + self._t("toolbar.toggle_theme"), lambda: self._invoke_action("toggle_theme")) + self.prefer_dark_action = QtGui.QAction("🌑 " + self._t("toolbar.prefer_dark"), self) + self.prefer_dark_action.setCheckable(True) + self.prefer_dark_action.setChecked(False) + self.prefer_dark_action.triggered.connect(lambda: self._invoke_action("toggle_prefer_dark")) + view_menu.addAction(self.prefer_dark_action) + view_menu.addSeparator() + view_menu.addAction("📁 " + self._t("toolbar.open_app_folder"), lambda: self._invoke_action("open_app_folder")) # Status label logic remains but moved to palette layout or kept minimal # We will add it to the palette layout so that it stays on top @@ -737,9 +749,38 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): layout.setSpacing(8) layout.setContentsMargins(0, 0, 0, 0) - self.filename_label = QtWidgets.QLabel("—") - self.filename_label.setAlignment(QtCore.Qt.AlignCenter) - layout.addWidget(self.filename_label) + # Status row container + status_row_layout = QtWidgets.QHBoxLayout() + status_row_layout.setSpacing(4) + status_row_layout.setAlignment(QtCore.Qt.AlignCenter) + + self.filename_prefix_label = QtWidgets.QLabel(self._t("status.loaded", name="", dimensions="", position="").split("{name}")[0]) + self.filename_prefix_label.setStyleSheet("color: " + THEMES["dark"]["text_muted"] + "; font-weight: 500;") + status_row_layout.addWidget(self.filename_prefix_label) + + self.pattern_input = QtWidgets.QLineEdit("—") + self.pattern_input.setAlignment(QtCore.Qt.AlignCenter) + self.pattern_input.setFixedWidth(100) + self.pattern_input.setStyleSheet( + "QLineEdit {" + " background: rgba(255, 255, 255, 0.05);" + " border: 1px solid rgba(255, 255, 255, 0.1);" + " border-radius: 4px;" + " color: " + THEMES["dark"]["text"] + ";" + " font-weight: 600;" + "}" + "QLineEdit:focus {" + " border: 1px solid " + THEMES["dark"]["accent"] + ";" + "}" + ) + self.pattern_input.returnPressed.connect(self._jump_to_pattern) + status_row_layout.addWidget(self.pattern_input) + + self.filename_suffix_label = QtWidgets.QLabel("") + self.filename_suffix_label.setStyleSheet("color: " + THEMES["dark"]["text"] + "; font-weight: 600;") + status_row_layout.addWidget(self.filename_suffix_label) + + layout.addLayout(status_row_layout) self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder")) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) @@ -763,6 +804,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): "undo_exclude": self.undo_exclusion, "reset_sliders": self._reset_sliders, "toggle_theme": self.toggle_theme, + "toggle_prefer_dark": self.toggle_prefer_dark, + "open_app_folder": self.open_app_folder, "show_previous_image": self.show_previous_image, "show_next_image": self.show_next_image, "pull_patterns": self.open_pattern_puller, @@ -773,6 +816,41 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): if action: action() + def _jump_to_pattern(self) -> None: + if not self.processor.preview_paths: + return + + target_text = self.pattern_input.text().strip() + if not target_text: + # Restore current text if empty + if self._current_image_path: + self.pattern_input.setText(self._current_image_path.stem) + return + + # Try to find exactly this name or stem + target_stem_lower = target_text.lower() + found_idx = -1 + + for i, path in enumerate(self.processor.preview_paths): + if path.stem.lower() == target_stem_lower or path.name.lower() == target_stem_lower: + found_idx = i + break + + if found_idx != -1: + # Found it, jump! + self.processor.current_index = found_idx + try: + loaded_path = self.processor._load_image_at_current() + self._current_image_path = loaded_path + self._refresh_views() + except Exception as e: + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) + else: + # Not found, just restore text + if self._current_image_path: + self.pattern_input.setText(self._current_image_path.stem) + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), f"Pattern '{target_text}' not found in current folder.") + # Image handling --------------------------------------------------------- def open_image(self) -> None: @@ -924,12 +1002,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): delimiter = ";" decimal = "," + brightness_col = "Darkness Score" if self.processor.prefer_dark else "Brightness Score" headers = [ "Filename", "Color", "Matching Pixels", "Matching Pixels w/ Exclusions", - "Excluded Pixels" + "Excluded Pixels", + brightness_col, + "Composite Score" ] rows = [headers] @@ -945,6 +1026,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): pct_all_str = f"{pct_all:.2f}".replace(".", decimal) pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal) + brightness_str = f"{s.effective_brightness:.2f}".replace(".", decimal) + composite_str = f"{s.composite_score:.2f}".replace(".", decimal) img.close() return [ @@ -952,10 +1035,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self._current_color, pct_all_str, pct_keep_str, - pct_excl_str + pct_excl_str, + brightness_str, + composite_str ] except Exception: - return [img_path.name, self._current_color, "Error", "Error", "Error"] + return [img_path.name, self._current_color, "Error", "Error", "Error", "Error", "Error"] results = [None] * total with concurrent.futures.ThreadPoolExecutor() as executor: @@ -1197,9 +1282,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): def toggle_free_draw(self) -> None: self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" self.image_view.set_mode(self.exclude_mode) + self.free_draw_action.setChecked(self.exclude_mode == "free") message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled" self.status_label.setText(self._t(message_key)) + def toggle_prefer_dark(self) -> None: + self.processor.prefer_dark = not self.processor.prefer_dark + self.prefer_dark_action.setChecked(self.processor.prefer_dark) + if self.processor.preview_img: + self.processor._rebuild_overlay() + self._refresh_overlay_only() + def clear_exclusions(self) -> None: self.image_view.clear_shapes() self.processor.set_exclusions([]) @@ -1214,6 +1307,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self.current_theme = "light" if self.current_theme == "dark" else "dark" self._apply_theme(self.current_theme) + def open_app_folder(self) -> None: + path = os.getcwd() + QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(path)) + def _apply_theme(self, mode: str) -> None: colors = THEMES[mode] self.content.setStyleSheet(f"background-color: {colors['window_bg']};") @@ -1229,7 +1326,20 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self.current_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};") self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") - self.filename_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;") + self.filename_prefix_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") + self.filename_suffix_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;") + self.pattern_input.setStyleSheet( + f"QLineEdit {{" + f" background: rgba(255, 255, 255, 0.05);" + f" border: 1px solid {colors['border']};" + f" border-radius: 4px;" + f" color: {colors['text']};" + f" font-weight: 600;" + f"}}" + f"QLineEdit:focus {{" + f" border: 1px solid {colors['accent']};" + f"}}" + ) self.ratio_label.setStyleSheet(f"color: {colors['highlight']}; font-weight: 600;") # Style MenuBar @@ -1319,12 +1429,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): total = len(self.processor.preview_paths) position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" dimensions = f"{width}×{height}" + + # Status label for top right layout self.status_label.setText( self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) ) - self.filename_label.setText( - self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) - ) + + # Pattern input + self.pattern_input.setText(self._current_image_path.stem) + + # Update suffix label + suffix_text = f"{self._current_image_path.suffix} — {dimensions}{position}" + self.filename_suffix_label.setText(suffix_text) + + # Update prefix translation correctly + prefix = self._t("status.loaded", name="X", dimensions="Y", position="Z").split("X")[0] + self.filename_prefix_label.setText(prefix) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _refresh_overlay_only(self) -> None: