From ef648cc6767fc9f37b26aeafb01f797417109828 Mon Sep 17 00:00:00 2001 From: lukas Date: Mon, 23 Mar 2026 21:00:16 +0100 Subject: [PATCH] Implement grouping score, customizable export weights, and fix color selection bug --- app/lang/de.toml | 18 ++++- app/lang/en.toml | 12 ++- app/qt/image_processor.py | 61 ++++++++++++-- app/qt/main_window.py | 148 +++++++++++++++++++++++++++++++--- tests/test_image_processor.py | 38 ++++++++- 5 files changed, 255 insertions(+), 22 deletions(-) diff --git a/app/lang/de.toml b/app/lang/de.toml index 37d5938..43f5ecf 100644 --- a/app/lang/de.toml +++ b/app/lang/de.toml @@ -45,9 +45,10 @@ "sliders.val_max" = "Helligkeit Max (%)" "sliders.alpha" = "Overlay Alpha" "stats.placeholder" = "Markierungen (mit Ausschlüssen): —" -"stats.summary" = "Score: {score:.2f}% | Markierungen (m. Ausschl.): {with_pct:.2f}% | Markierungen: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Ausgeschlossen: {excluded_pct:.2f}%" +"stats.summary" = "Wertung: {score:.2f}% | Treffer (mit Exkl.): {with_pct:.2f}% | Treffer: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Gruppierung: {grouping:.1f}% | Ausgeschlossen: {excluded_pct:.2f}%" "stats.brightness_label" = "Helligkeit" "stats.darkness_label" = "Dunkelheit" +"stats.grouping_label" = "Gruppierung" "menu.copy" = "Kopieren" "dialog.info_title" = "Info" "dialog.error_title" = "Fehler" @@ -83,10 +84,19 @@ "menu.view" = "Ansicht" "menu.tools" = "Werkzeuge" -"toolbar.pull_patterns" = "Muster-Bilder herunterladen" -"dialog.puller_title" = "Muster-Bilder herunterladen" -"dialog.puller_instruction" = "CSGOSkins.gg Artikel-URL einfügen:" +"toolbar.pull_patterns" = "Muster-Bilder laden" +"dialog.puller_title" = "Muster-Bilder laden" +"dialog.puller_instruction" = "CSGOSkins.gg Item-URL einfügen:" "dialog.puller_start" = "Download starten" "dialog.puller_cancel" = "Abbrechen" "dialog.puller_invalid_url" = "Ungültiges URL-Format." "dialog.puller_success" = "Alle Muster erfolgreich heruntergeladen!" + +"dialog.weighting_title" = "Export-Gewichtung & Präferenz" +"dialog.weighting_instruction" = "Gewichtung der Komponenten festlegen (Summe muss 100% sein):" +"dialog.weight_match_all" = "Treffer (Alle) %" +"dialog.weight_match_keep" = "Treffer (Behalten) %" +"dialog.weight_brightness" = "Helligkeit/Dunkelheit %" +"dialog.weight_grouping" = "Gruppierung %" +"dialog.total_weight" = "Gesamt:" +"dialog.weight_error" = "Die Summe muss genau 100% sein (aktuell {total}%)." diff --git a/app/lang/en.toml b/app/lang/en.toml index 0d3541e..25496f0 100644 --- a/app/lang/en.toml +++ b/app/lang/en.toml @@ -45,9 +45,10 @@ "sliders.val_max" = "Value max (%)" "sliders.alpha" = "Overlay alpha" "stats.placeholder" = "Matches (with exclusions): —" -"stats.summary" = "Score: {score:.2f}% | Matches (w/ excl.): {with_pct:.2f}% | Matches: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Excluded: {excluded_pct:.2f}%" +"stats.summary" = "Score: {score:.2f}% | Matches (w/ excl.): {with_pct:.2f}% | Matches: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Grouping: {grouping:.1f}% | Excluded: {excluded_pct:.2f}%" "stats.brightness_label" = "Brightness" "stats.darkness_label" = "Darkness" +"stats.grouping_label" = "Grouping" "menu.copy" = "Copy" "dialog.info_title" = "Info" "dialog.error_title" = "Error" @@ -90,3 +91,12 @@ "dialog.puller_cancel" = "Cancel" "dialog.puller_invalid_url" = "Invalid URL format." "dialog.puller_success" = "All patterns downloaded successfully!" + +"dialog.weighting_title" = "Export Weighting & Preference" +"dialog.weighting_instruction" = "Set the weighting for each component (total must be 100%):" +"dialog.weight_match_all" = "Match (All) %" +"dialog.weight_match_keep" = "Match (Keep) %" +"dialog.weight_brightness" = "Brightness/Darkness %" +"dialog.weight_grouping" = "Grouping %" +"dialog.total_weight" = "Total:" +"dialog.weight_error" = "Weights must sum exactly to 100% (currently {total}%)." diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index 2b4c35a..c502a91 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -23,6 +23,7 @@ class Stats: matches_excl: int = 0 total_excl: int = 0 brightness_score: float = 0.0 + grouping_score: float = 0.0 prefer_dark: bool = False @property @@ -30,27 +31,38 @@ class Stats: """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.""" + def composite_score(self, weights: dict[str, int]) -> float: + """Calculates weighted composite based on provided weights (0-100).""" 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 + + # weights keys: match_all, match_keep, brightness, grouping + w_all = weights.get("match_all", 30) / 100.0 + w_keep = weights.get("match_keep", 50) / 100.0 + w_bright = weights.get("brightness", 10) / 100.0 + w_group = weights.get("grouping", 10) / 100.0 + + return (w_all * pct_all + + w_keep * pct_keep + + w_bright * self.effective_brightness + + w_group * self.grouping_score) - def summary(self, translate) -> str: + def summary(self, translate, weights: dict[str, int]) -> str: if self.total_all == 0: return translate("stats.placeholder") 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 brightness_label = translate("stats.darkness_label") if self.prefer_dark else translate("stats.brightness_label") + score = self.composite_score(weights) return translate( "stats.summary", - score=self.composite_score, + score=score, with_pct=with_pct, without_pct=without_pct, brightness_label=brightness_label, brightness=self.effective_brightness, + grouping=self.grouping_score, excluded_pct=excluded_pct, ) @@ -130,6 +142,12 @@ class QtImageProcessor: self.exclude_bg: bool = True self.exclude_bg_rgb: Tuple[int, int, int] = (31, 41, 55) self.exclude_bg_tolerance: int = 5 + self.weights: Dict[str, int] = { + "match_all": 30, + "match_keep": 50, + "brightness": 10, + "grouping": 10 + } def set_defaults(self, defaults: dict) -> None: for key in self.defaults: @@ -273,6 +291,9 @@ class QtImageProcessor: # 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 + + # Grouping: measure clustering of match_mask + grouping = self._calculate_grouping_score(keep_match) # Build overlay image overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8) @@ -290,6 +311,7 @@ class QtImageProcessor: matches_excl=matches_excl, total_excl=total_excl, brightness_score=brightness, + grouping_score=grouping, prefer_dark=self.prefer_dark, ) @@ -343,6 +365,7 @@ class QtImageProcessor: 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 + grouping = self._calculate_grouping_score(keep_match) return Stats( matches_all=int(match_mask[visible].sum()), @@ -352,9 +375,35 @@ class QtImageProcessor: matches_excl=int(excl_match[visible].sum()), total_excl=int((visible & excl_mask).sum()), brightness_score=brightness, + grouping_score=grouping, prefer_dark=self.prefer_dark, ) + def _calculate_grouping_score(self, mask: np.ndarray) -> float: + """Measure clustering: average density in a 9x9 neighborhood (0-100).""" + if not mask.any(): + return 0.0 + + h, w = mask.shape + # Use cumulative sums for O(1) box sum calculation + padded = np.pad(mask, 5, mode='constant', constant_values=0) + cumsum = padded.astype(np.int32).cumsum(axis=0).cumsum(axis=1) + + # Indices for 9x9 windows centered at each mask pixel + y2, x2 = np.arange(9, 9 + h)[:, None], np.arange(9, 9 + w) + y1_1, x1_1 = np.arange(0, h)[:, None], np.arange(0, w) + + # Box sum formula: S(window) = S(x2,y2) - S(x1-1,y2) - S(x2,y1-1) + S(x1-1,y1-1) + window_sums = cumsum[y2, x2] - cumsum[y1_1, x2] - cumsum[y2, x1_1] + cumsum[y1_1, x1_1] + + # Max neighbors in 9x9 is 80 (excluding the center pixel itself) + neighbors = (window_sums - mask.astype(np.int32)).clip(min=0) + + match_neighbors = neighbors[mask] + # Square the density to heavily penalize thin bridges and frayed edges + score = ( (match_neighbors / 80.0) ** 2 ).mean() * 100.0 + return float(score) + # helpers ---------------------------------------------------------------- def _matches(self, r: int, g: int, b: int) -> bool: diff --git a/app/qt/main_window.py b/app/qt/main_window.py index 62923b2..ea8d150 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -513,6 +513,84 @@ class TitleBar(QtWidgets.QWidget): super().mousePressEvent(event) +class WeightingDialog(QtWidgets.QDialog): + """Dialog to customize weightings and preference before folder export.""" + + def __init__(self, parent: QtWidgets.QWidget, translate: Callable, current_weights: Dict[str, int], prefer_dark: bool): + super().__init__(parent) + self._t = translate + self.setWindowTitle(self._t("dialog.weighting_title")) + self.setFixedWidth(350) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(12) + + instruction = QtWidgets.QLabel(self._t("dialog.weighting_instruction")) + instruction.setWordWrap(True) + layout.addWidget(instruction) + + grid = QtWidgets.QGridLayout() + grid.setSpacing(10) + + self.inputs: Dict[str, QtWidgets.QSpinBox] = {} + specs = [ + ("match_all", "dialog.weight_match_all"), + ("match_keep", "dialog.weight_match_keep"), + ("brightness", "dialog.weight_brightness"), + ("grouping", "dialog.weight_grouping"), + ] + + for i, (key, label_key) in enumerate(specs): + label = QtWidgets.QLabel(self._t(label_key)) + spin = QtWidgets.QSpinBox() + spin.setRange(0, 100) + spin.setSuffix("%") + spin.setValue(current_weights.get(key, 0)) + spin.valueChanged.connect(self._update_total) + grid.addWidget(label, i, 0) + grid.addWidget(spin, i, 1) + self.inputs[key] = spin + + layout.addLayout(grid) + + self.total_label = QtWidgets.QLabel(f"{self._t('dialog.total_weight')} 100%") + self.total_label.setStyleSheet("font-weight: bold;") + layout.addWidget(self.total_label) + + self.prefer_dark_cb = QtWidgets.QCheckBox(self._t("toolbar.prefer_dark")) + self.prefer_dark_cb.setChecked(prefer_dark) + layout.addWidget(self.prefer_dark_cb) + + buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + buttons.accepted.connect(self._validate_and_accept) + buttons.rejected.connect(self.reject) + self.ok_button = buttons.button(QtWidgets.QDialogButtonBox.Ok) + layout.addWidget(buttons) + + self._update_total() + + def _update_total(self) -> None: + total = sum(s.value() for s in self.inputs.values()) + self.total_label.setText(f"{self._t('dialog.total_weight')} {total}%") + if total == 100: + self.total_label.setStyleSheet("color: #34c759; font-weight: bold;") + self.ok_button.setEnabled(True) + else: + self.total_label.setStyleSheet("color: #ff3b30; font-weight: bold;") + self.ok_button.setEnabled(False) + + def _validate_and_accept(self) -> None: + total = sum(s.value() for s in self.inputs.values()) + if total != 100: + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), self._t("dialog.weight_error", total=total)) + return + self.accept() + + def get_values(self) -> Tuple[Dict[str, int], bool]: + weights = {k: s.value() for k, s in self.inputs.items()} + return weights, self.prefer_dark_cb.isChecked() + + class MainWindow(QtWidgets.QMainWindow, I18nMixin): """Main application window containing all controls.""" @@ -698,7 +776,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): swatch_container.setSpacing(8) self.swatch_buttons: List[ColorSwatch] = [] for name_key, hex_code in PRESET_COLORS: - swatch = ColorSwatch(self._t(name_key), hex_code, self._update_color_display) + swatch = ColorSwatch(self._t(name_key), hex_code, lambda h, l: self._update_color_display(h, l, update_range=True)) swatch_container.addWidget(swatch) self.swatch_buttons.append(swatch) layout.addLayout(swatch_container) @@ -940,6 +1018,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): "val_min": self.processor.val_min, "val_max": self.processor.val_max, "alpha": self.processor.alpha, + "prefer_dark": self.processor.prefer_dark, + "weights": self.processor.weights, "current_color": self._current_color, "exclude_ref_size": self.processor.exclude_ref_size, "shapes": self.image_view.shapes @@ -1008,6 +1088,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): if self.processor.preview_img: self.processor._rebuild_overlay() + if "prefer_dark" in settings: + self.processor.prefer_dark = bool(settings["prefer_dark"]) + self.prefer_dark_action.setChecked(self.processor.prefer_dark) + + if "weights" in settings: + self.processor.weights = settings["weights"] + self._sync_sliders_from_processor() self._refresh_views() self.status_label.setText(self._t("status.settings_imported")) @@ -1042,13 +1129,25 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): if not csv_path: return + # Show weighting dialog + diag = WeightingDialog(self, self._t, self.processor.weights, self.processor.prefer_dark) + if diag.exec() != QtWidgets.QDialog.Accepted: + return + + new_weights, new_prefer_dark = diag.get_values() + self.processor.weights = new_weights + self.processor.prefer_dark = new_prefer_dark + # Update UI state + self.prefer_dark_action.setChecked(new_prefer_dark) + self._refresh_overlay_only() + total = len(self.processor.preview_paths) # Hardcoded to EU format as requested: ; delimiter, , decimal delimiter = ";" decimal = "," - brightness_col = "Darkness Score" if self.processor.prefer_dark else "Brightness Score" + brightness_col = self._t("stats.darkness_label") if self.processor.prefer_dark else self._t("stats.brightness_label") headers = [ "Filename", "Color", @@ -1056,6 +1155,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): "Matching Pixels w/ Exclusions", "Excluded Pixels", brightness_col, + self._t("stats.grouping_label"), "Composite Score" ] rows = [headers] @@ -1073,7 +1173,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): 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) + grouping_str = f"{s.grouping_score:.2f}".replace(".", decimal) + composite_str = f"{s.composite_score(self.processor.weights):.2f}".replace(".", decimal) img.close() return [ @@ -1083,10 +1184,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): pct_keep_str, pct_excl_str, brightness_str, + grouping_str, composite_str ] except Exception: - return [img_path.name, self._current_color, "Error", "Error", "Error", "Error", "Error"] + return [img_path.name, self._current_color, "Error", "Error", "Error", "Error", "Error", "Error"] results = [None] * total with concurrent.futures.ThreadPoolExecutor() as executor: @@ -1145,14 +1247,42 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): # Helpers ---------------------------------------------------------------- - def _update_color_display(self, hex_code: str, label: str) -> None: + def _update_color_display(self, hex_code: str, label: str, update_range: bool = False) -> None: self._current_color = hex_code self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_color_label.setText(f"({hex_code})") - # Do NOT call self.processor.set_overlay_color here to keep overlay RED if label: self.status_label.setText(f"{label}: {hex_code}") + if update_range: + # Convert hex to HSV and update sliders/thresholds + qcolor = QtGui.QColor(hex_code) + h, s, v, _ = qcolor.getHsv() + # QColor H is 0-359 or -1 for grayscale, S/V/A are 0-255 + hue = h if h >= 0 else 0 + sat_pct = (s / 255.0) * 100.0 + val_pct = (v / 255.0) * 100.0 + + margin = 15 + hue_min = max(0, int(hue) - margin) + hue_max = min(360, int(hue) + margin) + sat_min = max(0, int(sat_pct) - 20) + sat_max = min(100, int(sat_pct) + 20) + val_min = max(0, int(val_pct) - 30) + val_max = 100 + + for attr, val_score in [ + ("hue_min", hue_min), ("hue_max", hue_max), + ("sat_min", sat_min), ("sat_max", sat_max), + ("val_min", val_min), ("val_max", val_max), + ]: + ctrl = self._slider_controls.get(attr) + if ctrl: + # set_value on SliderControl blocks signals, so we set processor manually + ctrl.set_value(val_score) + self.processor.set_threshold(attr, val_score) + self._refresh_overlay_only() + def _on_slider_change(self, key: str, value: int) -> None: self.processor.set_threshold(key, value) label = self._slider_title(key) @@ -1307,7 +1437,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): if not color.isValid(): return hex_code = color.name() - self._update_color_display(hex_code, self._t("dialog.choose_color_title")) + self._update_color_display(hex_code, self._t("dialog.choose_color_title"), update_range=True) def save_overlay(self) -> None: pixmap = self.processor.overlay_pixmap() @@ -1502,7 +1632,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): # 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, self.processor.weights)) def _refresh_overlay_only(self) -> None: if self.processor.preview_img is None: @@ -1512,7 +1642,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self.overlay_view.clear_canvas() else: self.overlay_view.set_pixmap(self._overlay_with_outlines(pix)) - self.ratio_label.setText(self.processor.stats.summary(self._t)) + self.ratio_label.setText(self.processor.stats.summary(self._t, self.processor.weights)) def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None: self.processor.set_exclusions(shapes) diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index 41b331a..6fa0fce 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -19,7 +19,8 @@ def test_stats_summary(): return key return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f}" - res = s.summary(mock_t) + weights = {"match_all": 30, "match_keep": 50, "brightness": 10, "grouping": 10} + res = s.summary(mock_t, weights) # with_pct: 40/80 = 50.0 # without_pct: 50/100 = 50.0 # excluded_pct: 20/100 = 20.0 @@ -27,7 +28,8 @@ def test_stats_summary(): def test_stats_empty(): s = Stats() - assert s.summary(lambda k, **kw: "Empty") == "Empty" + weights = {"match_all": 30, "match_keep": 50, "brightness": 10, "grouping": 10} + assert s.summary(lambda k, **kw: "Empty", weights) == "Empty" def test_rgb_to_hsv_numpy(): @@ -121,3 +123,35 @@ def test_coordinate_scaling(): assert s_large.total_all == 1000000 assert s_large.total_keep == 500000 assert s_large.total_excl == 500000 + +def test_calculate_grouping_score(): + proc = QtImageProcessor() + + # 1. Empty mask + mask_empty = np.zeros((20, 20), dtype=bool) + assert proc._calculate_grouping_score(mask_empty) == 0.0 + + # 2. Single mask pixel (0 neighbors) + mask_single = np.zeros((20, 20), dtype=bool) + mask_single[10, 10] = True + assert proc._calculate_grouping_score(mask_single) == 0.0 + + # 3. 2x2 block + # each pixel in 2x2 has 3 neighbors in 3x3, 3 neighbors in 5x5, 3 neighbors in 9x9. + # score = ((3/80)^2) * 100 + expected_2x2 = ((3/80.0)**2) * 100.0 + mask_block = np.zeros((20, 20), dtype=bool) + mask_block[10:12, 10:12] = True + assert pytest.approx(proc._calculate_grouping_score(mask_block)) == expected_2x2 + + # 4. 9x9 block + # center pixel has 80 neighbors (100% density). + # many pixels have high density. + mask_9x9 = np.zeros((20, 20), dtype=bool) + mask_9x9[5:14, 5:14] = True + res_9x9 = proc._calculate_grouping_score(mask_9x9) + assert res_9x9 > expected_2x2 + # For a 9x9 block, the center pixel is 100%. Boundary pixels are less. + # 1 center pixel = 80/80 = 1.0. + # Overall it should be a healthy percentage. + assert res_9x9 > 10.0 # significant grouping