From a139a2e2bd231d18faa1021759077e98463fe6e8 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:22:58 +0200 Subject: [PATCH] =?UTF-8?q?Add=20redo,=20undo=C3=9710,=20brush=20preview,?= =?UTF-8?q?=20hide/show=20mask;=20drop=20Reload=20Saved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mask_canvas.py: - Redo stack: new strokes clear it, undo pushes onto it, redo pops from it. - undo10(): undoes up to 10 steps in one call with a single redraw. - Brush circle preview: white Circle patch tracks mouse position and shows current brush radius; hidden when cursor leaves the axes. - toggle_mask() / btn_mask: hides or shows the green mask overlay without affecting the underlying mask data. annotator.py: - Removed Reload Saved button and reload_saved() — clip already loads its saved state on navigation, making the button redundant. - Added Undo×10 and Redo buttons wired to mc.undo10 / mc.redo. - Added Hide Mask button (mc.btn_mask) to the toolbar row. Co-Authored-By: Claude Sonnet 4.6 --- src/river_annotation_tool/annotator.py | 24 ++++----- src/river_annotation_tool/mask_canvas.py | 63 ++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 508dbbb..1ae2d27 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -111,14 +111,22 @@ class Annotator(QMainWindow): btn_skip = QPushButton("Skip") btn_clear = QPushButton("Clear") btn_undo = QPushButton("Undo") - btn_reload = QPushButton("Reload Saved") + btn_undo10 = QPushButton("Undo×10") + btn_redo = QPushButton("Redo") row1 = QHBoxLayout() for b in [self.btn_prev, btn_next, btn_skip]: row1.addWidget(b) row2 = QHBoxLayout() - for b in [btn_clear, self.mc.btn_erase, btn_undo, btn_reload]: + for b in [ + btn_clear, + self.mc.btn_erase, + btn_undo, + btn_undo10, + btn_redo, + self.mc.btn_mask, + ]: row2.addWidget(b) row2.addWidget(QLabel("Brush")) row2.addWidget(self.mc.brush_slider) @@ -146,7 +154,8 @@ class Annotator(QMainWindow): btn_skip.clicked.connect(self.skip_clip) btn_clear.clicked.connect(self.mc.clear) btn_undo.clicked.connect(self.mc.undo) - btn_reload.clicked.connect(self.reload_saved) + btn_undo10.clicked.connect(self.mc.undo10) + btn_redo.clicked.connect(self.mc.redo) if self._pending_answers: self._set_answers(self._pending_answers) @@ -258,15 +267,6 @@ class Annotator(QMainWindow): print("Saved:", out) - def reload_saved(self): - mask = self._read_saved_mask() - if mask is None: - return - self.mc.reset(mask) - answers = self._read_saved_answers() - if answers: - self._set_answers(answers) - def _switch_ui_to_clip(self): self.frame_i = 0 self.mc.load_clip( diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index e98d37b..357b77d 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -1,12 +1,13 @@ import numpy as np from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure +from matplotlib.patches import Circle from PySide6.QtCore import Qt from PySide6.QtWidgets import QPushButton, QSlider class MaskCanvas: - """Matplotlib canvas with brush-based mask drawing, undo, and erase.""" + """Matplotlib canvas with brush-based mask drawing, undo/redo, and erase.""" def __init__(self, frames, dh: int, dw: int): self.dh = dh @@ -14,8 +15,10 @@ class MaskCanvas: self.mask = np.zeros((dh, dw), dtype=np.uint8) self.history: list[np.ndarray] = [] + self.redo_stack: list[np.ndarray] = [] self.erase_mode = False self.drawing = False + self.mask_visible = True self._build_figure(frames) self._build_controls() @@ -29,9 +32,14 @@ class MaskCanvas: self.img_artist = self.ax.imshow(frames[0]) self.mask_artist = self.ax.imshow(np.zeros((self.dh, self.dw, 4))) self.title_text = self.ax.set_title("", fontsize=10, pad=4) + self.brush_circle = Circle( + (0, 0), radius=5, fill=False, color="white", linewidth=1.5, visible=False + ) + self.ax.add_patch(self.brush_circle) def _build_controls(self): self.btn_erase = QPushButton("Eraser") + self.btn_mask = QPushButton("Hide Mask") self.brush_slider = QSlider(Qt.Horizontal) self.brush_slider.setRange(2, 50) self.brush_slider.setValue(5) @@ -40,7 +48,9 @@ class MaskCanvas: self.canvas.mpl_connect("button_press_event", self._on_press) self.canvas.mpl_connect("motion_notify_event", self._on_move) self.canvas.mpl_connect("button_release_event", self._on_release) + self.canvas.mpl_connect("axes_leave_event", self._on_axes_leave) self.btn_erase.clicked.connect(self.toggle_erase) + self.btn_mask.clicked.connect(self.toggle_mask) # ── clip transition ──────────────────────────────────────────── def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): @@ -48,6 +58,7 @@ class MaskCanvas: self.dw = dw self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8) self.history = [] + self.redo_stack = [] self.img_artist.set_data(frames[0]) self.set_title(title) self.redraw() @@ -62,14 +73,20 @@ class MaskCanvas: # ── mask ops ─────────────────────────────────────────────────── def reset(self, mask=None): - self.mask = mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8) + self.mask = ( + mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8) + ) self.history = [] + self.redo_stack = [] self.redraw() def redraw(self): - rgba = np.zeros((self.dh, self.dw, 4)) - rgba[..., 1] = self.mask * 0.7 - rgba[..., 3] = self.mask * 0.4 + if self.mask_visible: + rgba = np.zeros((self.dh, self.dw, 4)) + rgba[..., 1] = self.mask * 0.7 + rgba[..., 3] = self.mask * 0.4 + else: + rgba = np.zeros((self.dh, self.dw, 4)) self.mask_artist.set_data(rgba) self.canvas.draw_idle() @@ -79,17 +96,38 @@ class MaskCanvas: def undo(self): if self.history: + self.redo_stack.append(self.mask.copy()) self.mask = self.history.pop() self.redraw() + def undo10(self): + for _ in range(10): + if not self.history: + break + self.redo_stack.append(self.mask.copy()) + self.mask = self.history.pop() + self.redraw() + + def redo(self): + if self.redo_stack: + self.history.append(self.mask.copy()) + self.mask = self.redo_stack.pop() + self.redraw() + def toggle_erase(self): self.erase_mode = not self.erase_mode self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") + def toggle_mask(self): + self.mask_visible = not self.mask_visible + self.btn_mask.setText("Show Mask" if not self.mask_visible else "Hide Mask") + self.redraw() + def stamp(self, x, y): if x is None or y is None: return self.history.append(self.mask.copy()) + self.redo_stack.clear() r = self.brush_slider.value() ix, iy = int(x), int(y) y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1) @@ -99,6 +137,20 @@ class MaskCanvas: self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1 self.redraw() + # ── brush preview ────────────────────────────────────────────── + def _update_brush_preview(self, e): + if e.inaxes == self.ax and e.xdata is not None: + self.brush_circle.center = (e.xdata, e.ydata) + self.brush_circle.set_radius(self.brush_slider.value()) + self.brush_circle.set_visible(True) + else: + self.brush_circle.set_visible(False) + self.canvas.draw_idle() + + def _on_axes_leave(self, _): + self.brush_circle.set_visible(False) + self.canvas.draw_idle() + # ── mouse events ─────────────────────────────────────────────── def _on_press(self, e): if e.xdata is None: @@ -107,6 +159,7 @@ class MaskCanvas: self.stamp(e.xdata, e.ydata) def _on_move(self, e): + self._update_brush_preview(e) if self.drawing: self.stamp(e.xdata, e.ydata)