Add redo, undo×10, brush preview, hide/show mask; drop Reload Saved

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:22:58 +02:00
parent 5b6efc7158
commit a139a2e2bd
2 changed files with 70 additions and 17 deletions

View File

@@ -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(

View File

@@ -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)