Enable exclusion drawing in PySide6 UI

This commit is contained in:
lm 2025-10-19 19:52:41 +02:00
parent f46af5a735
commit 57bb896545
2 changed files with 339 additions and 17 deletions

View File

@ -19,13 +19,23 @@ class Stats:
total_all: int = 0
matches_keep: int = 0
total_keep: int = 0
matches_excl: int = 0
total_excl: int = 0
def summary(self, translate) -> 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
return translate("stats.summary", with_pct=with_pct, without_pct=without_pct, excluded_pct=0.0, excluded_match_pct=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
return translate(
"stats.summary",
with_pct=with_pct,
without_pct=without_pct,
excluded_pct=excluded_pct,
excluded_match_pct=excluded_match_pct,
)
class QtImageProcessor:
@ -55,6 +65,7 @@ class QtImageProcessor:
self.alpha = self.defaults["alpha"]
self.exclude_shapes: list[dict[str, object]] = []
self.reset_exclusions_on_switch: bool = False
def set_defaults(self, defaults: dict) -> None:
for key in self.defaults:
@ -62,6 +73,7 @@ class QtImageProcessor:
self.defaults[key] = int(defaults[key])
for key, value in self.defaults.items():
setattr(self, key, value)
self._rebuild_overlay()
# thresholds -------------------------------------------------------------
@ -78,6 +90,8 @@ class QtImageProcessor:
self.preview_paths = [path]
self.current_index = 0
self._build_preview()
if self.reset_exclusions_on_switch:
self.exclude_shapes = []
self._rebuild_overlay()
def load_folder(self, paths: Iterable[Path], start_index: int = 0) -> None:
@ -120,6 +134,7 @@ class QtImageProcessor:
def _rebuild_overlay(self) -> None:
if self.preview_img is None:
self.overlay_img = None
self.stats = Stats()
return
base = self.preview_img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
@ -128,19 +143,40 @@ class QtImageProcessor:
width, height = base.size
highlight = (255, 0, 0, int(self.alpha))
matches_all = total_all = 0
matches_keep = total_keep = 0
matches_excl = total_excl = 0
mask = self._build_exclusion_mask(base.size)
mask_px = mask.load() if mask else None
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a == 0:
continue
excluded = bool(mask_px and mask_px[x, y])
total_all += 1
if self._matches(r, g, b):
draw.point((x, y), fill=highlight)
matches_all += 1
if excluded:
total_excl += 1
if self._matches(r, g, b):
matches_excl += 1
else:
total_keep += 1
if self._matches(r, g, b):
matches_keep += 1
self.overlay_img = overlay
self.stats = Stats(matches_all=matches_all, total_all=total_all, matches_keep=matches_all, total_keep=total_all)
self.stats = Stats(
matches_all=matches_all,
total_all=total_all,
matches_keep=matches_keep,
total_keep=total_keep,
matches_excl=matches_excl,
total_excl=total_excl,
)
# helpers ----------------------------------------------------------------
@ -173,3 +209,34 @@ class QtImageProcessor:
buffer = image.tobytes("raw", "RGBA")
qt_image = QtGui.QImage(buffer, image.width, image.height, QtGui.QImage.Format_RGBA8888)
return QtGui.QPixmap.fromImage(qt_image)
# exclusions -------------------------------------------------------------
def set_exclusions(self, shapes: list[dict[str, object]]) -> None:
copied: list[dict[str, object]] = []
for shape in shapes:
kind = shape.get("kind")
if kind == "rect":
coords = tuple(shape.get("coords", (0, 0, 0, 0))) # type: ignore[assignment]
copied.append({"kind": "rect", "coords": tuple(int(c) for c in coords)})
elif kind == "polygon":
pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied
self._rebuild_overlay()
def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None:
if not self.exclude_shapes:
return None
mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(mask)
for shape in self.exclude_shapes:
kind = shape.get("kind")
if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
draw.rectangle([x0, y0, x1, y1], fill=255)
elif kind == "polygon":
points = shape.get("points", [])
if len(points) >= 3:
draw.polygon(points, fill=255)
return mask

View File

@ -232,6 +232,177 @@ class SliderControl(QtWidgets.QWidget):
)
class CanvasView(QtWidgets.QGraphicsView):
"""Interactive canvas for drawing exclusion shapes over the preview image."""
shapes_changed = QtCore.Signal(list)
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setMouseTracking(True)
self._scene = QtWidgets.QGraphicsScene(self)
self.setScene(self._scene)
self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None
self._shape_items: list[QtWidgets.QGraphicsItem] = []
self._rubber_item: QtWidgets.QGraphicsRectItem | None = None
self._stroke_item: QtWidgets.QGraphicsPathItem | None = None
self.shapes: list[dict[str, object]] = []
self.mode: str = "rect"
self._drawing = False
self._start_pos = QtCore.QPointF()
self._last_pos = QtCore.QPointF()
self._path = QtGui.QPainterPath()
self._accent = QtGui.QColor("#ffd700")
def set_pixmap(self, pixmap: QtGui.QPixmap) -> None:
self._scene.clear()
self._shape_items.clear()
self._pixmap_item = self._scene.addPixmap(pixmap)
self._scene.setSceneRect(pixmap.rect())
self.resetTransform()
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
self._redraw_shapes()
def clear_canvas(self) -> None:
if self._scene:
self._scene.clear()
self._pixmap_item = None
self._shape_items.clear()
self.shapes = []
def set_shapes(self, shapes: list[dict[str, object]]) -> None:
self.shapes = shapes
self._redraw_shapes()
def set_mode(self, mode: str) -> None:
self.mode = mode
def set_accent(self, colour: str) -> None:
self._accent = QtGui.QColor(colour)
self._redraw_shapes()
def undo_last(self) -> None:
if not self.shapes:
return
self.shapes.pop()
self._redraw_shapes()
self.shapes_changed.emit(self.shapes.copy())
def clear_shapes(self) -> None:
self.shapes = []
self._redraw_shapes()
self.shapes_changed.emit([])
# event handling --------------------------------------------------------
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
if event.button() == QtCore.Qt.RightButton and self._pixmap_item:
self._drawing = True
scene_pos = self.mapToScene(event.position().toPoint())
self._start_pos = self._clamp_to_image(scene_pos)
if self.mode == "rect":
pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)
brush = QtGui.QBrush(QtCore.Qt.NoBrush)
self._rubber_item = self._scene.addRect(QtCore.QRectF(self._start_pos, self._start_pos), pen, brush)
else:
pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)
self._path = QtGui.QPainterPath(self._start_pos)
self._stroke_item = self._scene.addPath(self._path, pen)
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
if self._drawing and self._pixmap_item:
scene_pos = self.mapToScene(event.position().toPoint())
self._last_pos = self._clamp_to_image(scene_pos)
if self.mode == "rect" and self._rubber_item:
rect = QtCore.QRectF(self._start_pos, self._last_pos).normalized()
self._rubber_item.setRect(rect)
elif self.mode == "free" and self._stroke_item:
self._path.lineTo(self._last_pos)
self._stroke_item.setPath(self._path)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
if self._drawing and event.button() == QtCore.Qt.RightButton and self._pixmap_item:
scene_pos = self.mapToScene(event.position().toPoint())
end_pos = self._clamp_to_image(scene_pos)
if self.mode == "rect" and self._rubber_item:
rect = QtCore.QRectF(self._start_pos, end_pos).normalized()
if rect.width() > 2 and rect.height() > 2:
shape = {
"kind": "rect",
"coords": (
int(rect.left()),
int(rect.top()),
int(rect.right()),
int(rect.bottom()),
),
}
self.shapes.append(shape)
self._scene.removeItem(self._rubber_item)
self._rubber_item = None
elif self.mode == "free" and self._stroke_item:
self._path.lineTo(self._start_pos)
points = [(int(pt.x()), int(pt.y())) for pt in self._path.toFillPolygon()]
if len(points) >= 3:
shape = {"kind": "polygon", "points": points}
self.shapes.append(shape)
self._scene.removeItem(self._stroke_item)
self._stroke_item = None
self._drawing = False
self._redraw_shapes()
self.shapes_changed.emit(self.shapes.copy())
event.accept()
return
super().mouseReleaseEvent(event)
# helpers ----------------------------------------------------------------
def _clamp_to_image(self, pos: QtCore.QPointF) -> QtCore.QPointF:
if not self._pixmap_item:
return pos
pixmap = self._pixmap_item.pixmap()
x = min(max(0.0, pos.x()), float(pixmap.width() - 1))
y = min(max(0.0, pos.y()), float(pixmap.height() - 1))
return QtCore.QPointF(x, y)
def _redraw_shapes(self) -> None:
for item in self._shape_items:
self._scene.removeItem(item)
self._shape_items.clear()
if not self._pixmap_item:
return
pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)
pen.setCosmetic(True)
for shape in self.shapes:
kind = shape.get("kind")
if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
rect_item = self._scene.addRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0), pen)
self._shape_items.append(rect_item)
elif kind == "polygon":
points = shape.get("points", [])
if len(points) < 2:
continue
path = QtGui.QPainterPath()
first = QtCore.QPointF(*points[0])
path.moveTo(first)
for px, py in points[1:]:
path.lineTo(px, py)
path.closeSubpath()
path_item = self._scene.addPath(path, pen)
self._shape_items.append(path_item)
class TitleBar(QtWidgets.QWidget):
"""Custom title bar with native window controls."""
@ -385,6 +556,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._register_default_actions()
self.exclude_mode = "rect"
self.image_view.set_mode(self.exclude_mode)
self.image_view.shapes_changed.connect(self._on_shapes_changed)
self._sync_sliders_from_processor()
self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
@ -499,12 +674,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image"))
layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter)
self.image_view = QtWidgets.QLabel("<No image loaded>")
self.image_view.setAlignment(QtCore.Qt.AlignCenter)
self.image_view = CanvasView()
layout.addWidget(self.image_view, 0, 1)
self.overlay_view = QtWidgets.QLabel("<No image loaded>")
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
self.overlay_view.setScaledContents(True)
layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton()
@ -537,12 +712,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self.open_folder,
"choose_color": self._coming_soon,
"choose_color": self.choose_colour,
"pick_from_image": self._coming_soon,
"save_overlay": self._coming_soon,
"toggle_free_draw": self._coming_soon,
"clear_excludes": self._coming_soon,
"undo_exclude": self._coming_soon,
"save_overlay": self.save_overlay,
"toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions,
"undo_exclude": self.undo_exclusion,
"reset_sliders": self._reset_sliders,
"toggle_theme": self.toggle_theme,
"show_previous_image": self.show_previous_image,
@ -568,6 +743,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
return
self._current_image_path = path
if self.processor.reset_exclusions_on_switch:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
self._refresh_views()
def open_folder(self) -> None:
@ -596,6 +774,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return
self.processor.previous_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
if self.processor.reset_exclusions_on_switch:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
self._refresh_views()
def show_next_image(self) -> None:
@ -604,6 +785,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return
self.processor.next_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
if self.processor.reset_exclusions_on_switch:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
self._refresh_views()
# Helpers ----------------------------------------------------------------
@ -616,16 +800,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _on_slider_change(self, key: str, value: int) -> None:
self.processor.set_threshold(key, value)
formatted = self._t("status.defaults_restored")
self.status_label.setText(f"{formatted} ({key}{value})")
label = self._slider_title(key)
self.status_label.setText(f"{label}: {value}")
self._refresh_overlay_only()
def _reset_sliders(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
control = self._slider_controls.get(attr)
if control:
default_value = int(getattr(self.processor, attr))
default_value = int(self.processor.defaults.get(attr, getattr(self.processor, attr)))
control.set_value(default_value)
self.processor.set_threshold(attr, default_value)
self.status_label.setText(self._t("status.defaults_restored"))
self._refresh_overlay_only()
@ -636,6 +821,47 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"Feature coming soon in the PySide6 migration.",
)
def choose_colour(self) -> None:
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title"))
if not colour.isValid():
return
hex_code = colour.name()
self._update_colour_display(hex_code, self._t("dialog.choose_colour_title"))
def save_overlay(self) -> None:
pixmap = self.processor.overlay_pixmap()
if pixmap.isNull():
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_preview_available"))
return
filename, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
self._t("dialog.save_overlay_title"),
"overlay.png",
"PNG (*.png)",
)
if not filename:
return
if not pixmap.save(filename, "PNG"):
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), self._t("dialog.image_open_failed", error="Unable to save file"))
return
self.status_label.setText(self._t("dialog.overlay_saved", path=filename))
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)
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 clear_exclusions(self) -> None:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
self.status_label.setText(self._t("toolbar.clear_excludes"))
self._refresh_overlay_only()
def undo_exclusion(self) -> None:
self.image_view.undo_last()
self.status_label.setText(self._t("toolbar.undo_exclude"))
def toggle_theme(self) -> None:
self.current_theme = "light" if self.current_theme == "dark" else "dark"
self._apply_theme(self.current_theme)
@ -643,8 +869,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _apply_theme(self, mode: str) -> None:
colours = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colours['window_bg']};")
self.image_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};")
self.overlay_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};")
self.image_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
)
self.image_view.set_accent(colours["highlight"])
self.overlay_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};"
)
self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
@ -676,11 +907,25 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if control:
control.set_value(int(getattr(self.processor, attr)))
def _slider_title(self, key: str) -> str:
for title_key, attr, _, _ in SLIDER_SPECS:
if attr == key:
return self._t(title_key)
return key
def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
self.image_view.setPixmap(preview_pix)
self.overlay_view.setPixmap(overlay_pix)
if preview_pix.isNull():
self.image_view.clear_canvas()
self.image_view.set_shapes([])
self.overlay_view.setText("<No image loaded>")
self.overlay_view.setPixmap(QtGui.QPixmap())
else:
self.image_view.set_pixmap(preview_pix)
self.image_view.set_shapes(self.processor.exclude_shapes.copy())
self.overlay_view.setText("")
self.overlay_view.setPixmap(overlay_pix)
if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size
total = len(self.processor.preview_paths)
@ -697,5 +942,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None:
return
self.overlay_view.setPixmap(self.processor.overlay_pixmap())
pix = self.processor.overlay_pixmap()
if pix.isNull():
self.overlay_view.setText("<No overlay>")
self.overlay_view.setPixmap(QtGui.QPixmap())
else:
self.overlay_view.setText("")
self.overlay_view.setPixmap(pix)
self.ratio_label.setText(self.processor.stats.summary(self._t))
def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None:
self.processor.set_exclusions(shapes)
self._refresh_overlay_only()