Implement grouping score, customizable export weights, and fix color selection bug

This commit is contained in:
lukas 2026-03-23 21:00:16 +01:00
parent 1c48a53c19
commit ef648cc676
5 changed files with 255 additions and 22 deletions

View File

@ -45,9 +45,10 @@
"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" = "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.brightness_label" = "Helligkeit"
"stats.darkness_label" = "Dunkelheit" "stats.darkness_label" = "Dunkelheit"
"stats.grouping_label" = "Gruppierung"
"menu.copy" = "Kopieren" "menu.copy" = "Kopieren"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Fehler" "dialog.error_title" = "Fehler"
@ -83,10 +84,19 @@
"menu.view" = "Ansicht" "menu.view" = "Ansicht"
"menu.tools" = "Werkzeuge" "menu.tools" = "Werkzeuge"
"toolbar.pull_patterns" = "Muster-Bilder herunterladen" "toolbar.pull_patterns" = "Muster-Bilder laden"
"dialog.puller_title" = "Muster-Bilder herunterladen" "dialog.puller_title" = "Muster-Bilder laden"
"dialog.puller_instruction" = "CSGOSkins.gg Artikel-URL einfügen:" "dialog.puller_instruction" = "CSGOSkins.gg Item-URL einfügen:"
"dialog.puller_start" = "Download starten" "dialog.puller_start" = "Download starten"
"dialog.puller_cancel" = "Abbrechen" "dialog.puller_cancel" = "Abbrechen"
"dialog.puller_invalid_url" = "Ungültiges URL-Format." "dialog.puller_invalid_url" = "Ungültiges URL-Format."
"dialog.puller_success" = "Alle Muster erfolgreich heruntergeladen!" "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}%)."

View File

@ -45,9 +45,10 @@
"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" = "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.brightness_label" = "Brightness"
"stats.darkness_label" = "Darkness" "stats.darkness_label" = "Darkness"
"stats.grouping_label" = "Grouping"
"menu.copy" = "Copy" "menu.copy" = "Copy"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Error" "dialog.error_title" = "Error"
@ -90,3 +91,12 @@
"dialog.puller_cancel" = "Cancel" "dialog.puller_cancel" = "Cancel"
"dialog.puller_invalid_url" = "Invalid URL format." "dialog.puller_invalid_url" = "Invalid URL format."
"dialog.puller_success" = "All patterns downloaded successfully!" "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}%)."

View File

@ -23,6 +23,7 @@ class Stats:
matches_excl: int = 0 matches_excl: int = 0
total_excl: int = 0 total_excl: int = 0
brightness_score: float = 0.0 brightness_score: float = 0.0
grouping_score: float = 0.0
prefer_dark: bool = False prefer_dark: bool = False
@property @property
@ -30,27 +31,38 @@ class Stats:
"""Returns inverted brightness when prefer_dark is on.""" """Returns inverted brightness when prefer_dark is on."""
return (100.0 - self.brightness_score) if self.prefer_dark else self.brightness_score return (100.0 - self.brightness_score) if self.prefer_dark else self.brightness_score
@property def composite_score(self, weights: dict[str, int]) -> float:
def composite_score(self) -> float: """Calculates weighted composite based on provided weights (0-100)."""
"""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_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 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: if self.total_all == 0:
return translate("stats.placeholder") return translate("stats.placeholder")
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
brightness_label = translate("stats.darkness_label") if self.prefer_dark else translate("stats.brightness_label") brightness_label = translate("stats.darkness_label") if self.prefer_dark else translate("stats.brightness_label")
score = self.composite_score(weights)
return translate( return translate(
"stats.summary", "stats.summary",
score=self.composite_score, score=score,
with_pct=with_pct, with_pct=with_pct,
without_pct=without_pct, without_pct=without_pct,
brightness_label=brightness_label, brightness_label=brightness_label,
brightness=self.effective_brightness, brightness=self.effective_brightness,
grouping=self.grouping_score,
excluded_pct=excluded_pct, excluded_pct=excluded_pct,
) )
@ -130,6 +142,12 @@ class QtImageProcessor:
self.exclude_bg: bool = True self.exclude_bg: bool = True
self.exclude_bg_rgb: Tuple[int, int, int] = (31, 41, 55) self.exclude_bg_rgb: Tuple[int, int, int] = (31, 41, 55)
self.exclude_bg_tolerance: int = 5 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: def set_defaults(self, defaults: dict) -> None:
for key in self.defaults: for key in self.defaults:
@ -273,6 +291,9 @@ class QtImageProcessor:
# Brightness: mean Value (0-100) of ALL non-excluded visible pixels # Brightness: mean Value (0-100) of ALL non-excluded visible pixels
keep_visible = visible & ~excl_mask keep_visible = visible & ~excl_mask
brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0 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 # 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)
@ -290,6 +311,7 @@ class QtImageProcessor:
matches_excl=matches_excl, matches_excl=matches_excl,
total_excl=total_excl, total_excl=total_excl,
brightness_score=brightness, brightness_score=brightness,
grouping_score=grouping,
prefer_dark=self.prefer_dark, prefer_dark=self.prefer_dark,
) )
@ -343,6 +365,7 @@ class QtImageProcessor:
matches_keep_count = int(keep_match[visible].sum()) matches_keep_count = int(keep_match[visible].sum())
keep_visible = visible & ~excl_mask keep_visible = visible & ~excl_mask
brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0 brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0
grouping = self._calculate_grouping_score(keep_match)
return Stats( return Stats(
matches_all=int(match_mask[visible].sum()), matches_all=int(match_mask[visible].sum()),
@ -352,9 +375,35 @@ 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_score=brightness, brightness_score=brightness,
grouping_score=grouping,
prefer_dark=self.prefer_dark, 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 ---------------------------------------------------------------- # helpers ----------------------------------------------------------------
def _matches(self, r: int, g: int, b: int) -> bool: def _matches(self, r: int, g: int, b: int) -> bool:

View File

@ -513,6 +513,84 @@ class TitleBar(QtWidgets.QWidget):
super().mousePressEvent(event) 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): class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"""Main application window containing all controls.""" """Main application window containing all controls."""
@ -698,7 +776,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
swatch_container.setSpacing(8) swatch_container.setSpacing(8)
self.swatch_buttons: List[ColorSwatch] = [] self.swatch_buttons: List[ColorSwatch] = []
for name_key, hex_code in PRESET_COLORS: 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) swatch_container.addWidget(swatch)
self.swatch_buttons.append(swatch) self.swatch_buttons.append(swatch)
layout.addLayout(swatch_container) layout.addLayout(swatch_container)
@ -940,6 +1018,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"val_min": self.processor.val_min, "val_min": self.processor.val_min,
"val_max": self.processor.val_max, "val_max": self.processor.val_max,
"alpha": self.processor.alpha, "alpha": self.processor.alpha,
"prefer_dark": self.processor.prefer_dark,
"weights": self.processor.weights,
"current_color": self._current_color, "current_color": self._current_color,
"exclude_ref_size": self.processor.exclude_ref_size, "exclude_ref_size": self.processor.exclude_ref_size,
"shapes": self.image_view.shapes "shapes": self.image_view.shapes
@ -1008,6 +1088,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if self.processor.preview_img: if self.processor.preview_img:
self.processor._rebuild_overlay() 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._sync_sliders_from_processor()
self._refresh_views() self._refresh_views()
self.status_label.setText(self._t("status.settings_imported")) self.status_label.setText(self._t("status.settings_imported"))
@ -1042,13 +1129,25 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if not csv_path: if not csv_path:
return 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) total = len(self.processor.preview_paths)
# Hardcoded to EU format as requested: ; delimiter, , decimal # Hardcoded to EU format as requested: ; delimiter, , decimal
delimiter = ";" delimiter = ";"
decimal = "," 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 = [ headers = [
"Filename", "Filename",
"Color", "Color",
@ -1056,6 +1155,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"Matching Pixels w/ Exclusions", "Matching Pixels w/ Exclusions",
"Excluded Pixels", "Excluded Pixels",
brightness_col, brightness_col,
self._t("stats.grouping_label"),
"Composite Score" "Composite Score"
] ]
rows = [headers] rows = [headers]
@ -1073,7 +1173,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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) 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() img.close()
return [ return [
@ -1083,10 +1184,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
pct_keep_str, pct_keep_str,
pct_excl_str, pct_excl_str,
brightness_str, brightness_str,
grouping_str,
composite_str composite_str
] ]
except Exception: 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 results = [None] * total
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
@ -1145,14 +1247,42 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# Helpers ---------------------------------------------------------------- # 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 = hex_code
self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_color_label.setText(f"({hex_code})") self.current_color_label.setText(f"({hex_code})")
# Do NOT call self.processor.set_overlay_color here to keep overlay RED
if label: if label:
self.status_label.setText(f"{label}: {hex_code}") 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: def _on_slider_change(self, key: str, value: int) -> None:
self.processor.set_threshold(key, value) self.processor.set_threshold(key, value)
label = self._slider_title(key) label = self._slider_title(key)
@ -1307,7 +1437,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if not color.isValid(): if not color.isValid():
return return
hex_code = color.name() 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: def save_overlay(self) -> None:
pixmap = self.processor.overlay_pixmap() pixmap = self.processor.overlay_pixmap()
@ -1502,7 +1632,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# Update prefix translation correctly # Update prefix translation correctly
prefix = self._t("status.loaded", name="X", dimensions="Y", position="Z").split("X")[0] prefix = self._t("status.loaded", name="X", dimensions="Y", position="Z").split("X")[0]
self.filename_prefix_label.setText(prefix) 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: def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None: if self.processor.preview_img is None:
@ -1512,7 +1642,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.overlay_view.clear_canvas() self.overlay_view.clear_canvas()
else: else:
self.overlay_view.set_pixmap(self._overlay_with_outlines(pix)) 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: def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None:
self.processor.set_exclusions(shapes) self.processor.set_exclusions(shapes)

View File

@ -19,7 +19,8 @@ def test_stats_summary():
return key return key
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f}" 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 # with_pct: 40/80 = 50.0
# without_pct: 50/100 = 50.0 # without_pct: 50/100 = 50.0
# excluded_pct: 20/100 = 20.0 # excluded_pct: 20/100 = 20.0
@ -27,7 +28,8 @@ def test_stats_summary():
def test_stats_empty(): def test_stats_empty():
s = Stats() 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(): def test_rgb_to_hsv_numpy():
@ -121,3 +123,35 @@ def test_coordinate_scaling():
assert s_large.total_all == 1000000 assert s_large.total_all == 1000000
assert s_large.total_keep == 500000 assert s_large.total_keep == 500000
assert s_large.total_excl == 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