Implement grouping score, customizable export weights, and fix color selection bug
This commit is contained in:
parent
1c48a53c19
commit
ef648cc676
|
|
@ -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}%)."
|
||||
|
|
|
|||
|
|
@ -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}%)."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
def summary(self, translate) -> str:
|
||||
# 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, 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:
|
||||
|
|
@ -274,6 +292,9 @@ class QtImageProcessor:
|
|||
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)
|
||||
overlay_arr[keep_match, 0] = self.overlay_r
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue