Feature: Advanced Navigation & Composite Scoring

- Added direct pattern jump input field (Ctrl+J behavior without hotkey)

- Implemented Composite Score: 35% match, 55% excl match, 10% brightness

- Added 'Prefer darkness' toggle to invert brightness score

- Added checkmarks to 'Prefer darkness' and 'Toggle free-draw' menus

- Added dynamic Darkness/Brightness text parsing to UI and CSV export
This commit is contained in:
lukas 2026-03-11 14:48:24 +01:00
parent acfcf99d15
commit 7f219885bf
4 changed files with 173 additions and 16 deletions

View File

@ -10,6 +10,8 @@
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen" "toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
"toolbar.reset_sliders" = "Slider zurücksetzen" "toolbar.reset_sliders" = "Slider zurücksetzen"
"toolbar.toggle_theme" = "Theme umschalten" "toolbar.toggle_theme" = "Theme umschalten"
"toolbar.open_app_folder" = "Programmordner öffnen"
"toolbar.prefer_dark" = "Dunkelheit bevorzugen"
"status.no_file" = "Keine Datei geladen." "status.no_file" = "Keine Datei geladen."
"status.defaults_restored" = "Standardwerte aktiv." "status.defaults_restored" = "Standardwerte aktiv."
"status.free_draw_enabled" = "Freihand-Ausschluss aktiviert." "status.free_draw_enabled" = "Freihand-Ausschluss aktiviert."
@ -41,7 +43,9 @@
"sliders.val_max" = "Helligkeit Max (%)" "sliders.val_max" = "Helligkeit Max (%)"
"sliders.alpha" = "Overlay Alpha" "sliders.alpha" = "Overlay Alpha"
"stats.placeholder" = "Markierungen (mit Ausschlüssen): —" "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" "menu.copy" = "Kopieren"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Fehler" "dialog.error_title" = "Fehler"

View File

@ -10,6 +10,8 @@
"toolbar.undo_exclude" = "Undo last exclusion" "toolbar.undo_exclude" = "Undo last exclusion"
"toolbar.reset_sliders" = "Reset sliders" "toolbar.reset_sliders" = "Reset sliders"
"toolbar.toggle_theme" = "Toggle theme" "toolbar.toggle_theme" = "Toggle theme"
"toolbar.open_app_folder" = "Open application folder"
"toolbar.prefer_dark" = "Prefer darkness"
"status.no_file" = "No file loaded." "status.no_file" = "No file loaded."
"status.defaults_restored" = "Defaults restored." "status.defaults_restored" = "Defaults restored."
"status.free_draw_enabled" = "Free-draw exclusion mode enabled." "status.free_draw_enabled" = "Free-draw exclusion mode enabled."
@ -41,7 +43,9 @@
"sliders.val_max" = "Value max (%)" "sliders.val_max" = "Value max (%)"
"sliders.alpha" = "Overlay alpha" "sliders.alpha" = "Overlay alpha"
"stats.placeholder" = "Matches (with exclusions): —" "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" "menu.copy" = "Copy"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Error" "dialog.error_title" = "Error"

View File

@ -22,6 +22,20 @@ class Stats:
total_keep: int = 0 total_keep: int = 0
matches_excl: int = 0 matches_excl: int = 0
total_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: def summary(self, translate) -> str:
if self.total_all == 0: 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 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 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_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( return translate(
"stats.summary", "stats.summary",
score=self.composite_score,
with_pct=with_pct, with_pct=with_pct,
without_pct=without_pct, without_pct=without_pct,
brightness_label=brightness_label,
brightness=self.effective_brightness,
excluded_pct=excluded_pct, 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: np.ndarray | None = None
self._cached_mask_size: Tuple[int, int] | None = None self._cached_mask_size: Tuple[int, int] | None = None
self.exclude_ref_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: def set_defaults(self, defaults: dict) -> None:
for key in self.defaults: for key in self.defaults:
@ -221,6 +238,10 @@ class QtImageProcessor:
matches_excl = int(excl_match[visible].sum()) matches_excl = int(excl_match[visible].sum())
total_excl = int((visible & excl_mask).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 # Build overlay image
overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8) overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8)
overlay_arr[keep_match, 0] = self.overlay_r overlay_arr[keep_match, 0] = self.overlay_r
@ -236,6 +257,8 @@ class QtImageProcessor:
total_keep=total_keep, total_keep=total_keep,
matches_excl=matches_excl, matches_excl=matches_excl,
total_excl=total_excl, total_excl=total_excl,
brightness_score=brightness,
prefer_dark=self.prefer_dark,
) )
def get_stats_headless(self, image: Image.Image) -> Stats: def get_stats_headless(self, image: Image.Image) -> Stats:
@ -273,13 +296,19 @@ class QtImageProcessor:
excl_match = match_mask & excl_mask excl_match = match_mask & excl_mask
visible = alpha_ch > 0 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( return Stats(
matches_all=int(match_mask[visible].sum()), matches_all=int(match_mask[visible].sum()),
total_all=int(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()), total_keep=int((visible & ~excl_mask).sum()),
matches_excl=int(excl_match[visible].sum()), matches_excl=int(excl_match[visible].sum()),
total_excl=int((visible & excl_mask).sum()), total_excl=int((visible & excl_mask).sum()),
brightness_score=brightness,
prefer_dark=self.prefer_dark,
) )
# helpers ---------------------------------------------------------------- # helpers ----------------------------------------------------------------

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import os
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
@ -637,13 +638,24 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
tools_menu = self.menu_bar.addMenu(self._t("menu.tools")) 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.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.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.addSeparator()
tools_menu.addAction("📥 " + self._t("toolbar.pull_patterns"), lambda: self._invoke_action("pull_patterns")) tools_menu.addAction("📥 " + self._t("toolbar.pull_patterns"), lambda: self._invoke_action("pull_patterns"))
# View Menu # View Menu
view_menu = self.menu_bar.addMenu(self._t("menu.view")) view_menu = self.menu_bar.addMenu(self._t("menu.view"))
view_menu.addAction("🌓 " + self._t("toolbar.toggle_theme"), lambda: self._invoke_action("toggle_theme")) 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 # 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 # 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.setSpacing(8)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
self.filename_label = QtWidgets.QLabel("") # Status row container
self.filename_label.setAlignment(QtCore.Qt.AlignCenter) status_row_layout = QtWidgets.QHBoxLayout()
layout.addWidget(self.filename_label) 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 = QtWidgets.QLabel(self._t("stats.placeholder"))
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
@ -763,6 +804,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"undo_exclude": self.undo_exclusion, "undo_exclude": self.undo_exclusion,
"reset_sliders": self._reset_sliders, "reset_sliders": self._reset_sliders,
"toggle_theme": self.toggle_theme, "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_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image, "show_next_image": self.show_next_image,
"pull_patterns": self.open_pattern_puller, "pull_patterns": self.open_pattern_puller,
@ -773,6 +816,41 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if action: if action:
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 --------------------------------------------------------- # Image handling ---------------------------------------------------------
def open_image(self) -> None: def open_image(self) -> None:
@ -924,12 +1002,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
delimiter = ";" delimiter = ";"
decimal = "," decimal = ","
brightness_col = "Darkness Score" if self.processor.prefer_dark else "Brightness Score"
headers = [ headers = [
"Filename", "Filename",
"Color", "Color",
"Matching Pixels", "Matching Pixels",
"Matching Pixels w/ Exclusions", "Matching Pixels w/ Exclusions",
"Excluded Pixels" "Excluded Pixels",
brightness_col,
"Composite Score"
] ]
rows = [headers] rows = [headers]
@ -945,6 +1026,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
pct_all_str = f"{pct_all:.2f}".replace(".", decimal) pct_all_str = f"{pct_all:.2f}".replace(".", decimal)
pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal)
pct_excl_str = f"{pct_excl:.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() img.close()
return [ return [
@ -952,10 +1035,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._current_color, self._current_color,
pct_all_str, pct_all_str,
pct_keep_str, pct_keep_str,
pct_excl_str pct_excl_str,
brightness_str,
composite_str
] ]
except Exception: 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 results = [None] * total
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
@ -1197,9 +1282,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def toggle_free_draw(self) -> None: def toggle_free_draw(self) -> None:
self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect"
self.image_view.set_mode(self.exclude_mode) 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" message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled"
self.status_label.setText(self._t(message_key)) 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: def clear_exclusions(self) -> None:
self.image_view.clear_shapes() self.image_view.clear_shapes()
self.processor.set_exclusions([]) 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.current_theme = "light" if self.current_theme == "dark" else "dark"
self._apply_theme(self.current_theme) 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: def _apply_theme(self, mode: str) -> None:
colors = THEMES[mode] colors = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colors['window_bg']};") 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_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};") self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};")
self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") 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;") self.ratio_label.setStyleSheet(f"color: {colors['highlight']}; font-weight: 600;")
# Style MenuBar # Style MenuBar
@ -1319,12 +1429,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
total = len(self.processor.preview_paths) total = len(self.processor.preview_paths)
position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else ""
dimensions = f"{width}×{height}" dimensions = f"{width}×{height}"
# Status label for top right layout
self.status_label.setText( self.status_label.setText(
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) 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)) self.ratio_label.setText(self.processor.stats.summary(self._t))
def _refresh_overlay_only(self) -> None: def _refresh_overlay_only(self) -> None: