diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index f0a506e..dc9e834 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -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 diff --git a/app/qt/main_window.py b/app/qt/main_window.py index 988a29e..e945ef9 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -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("") - self.image_view.setAlignment(QtCore.Qt.AlignCenter) + self.image_view = CanvasView() layout.addWidget(self.image_view, 0, 1) self.overlay_view = QtWidgets.QLabel("") 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("") + 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("") + 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()