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_skip = QPushButton("Skip")
btn_clear = QPushButton("Clear") btn_clear = QPushButton("Clear")
btn_undo = QPushButton("Undo") btn_undo = QPushButton("Undo")
btn_reload = QPushButton("Reload Saved") btn_undo10 = QPushButton("Undo×10")
btn_redo = QPushButton("Redo")
row1 = QHBoxLayout() row1 = QHBoxLayout()
for b in [self.btn_prev, btn_next, btn_skip]: for b in [self.btn_prev, btn_next, btn_skip]:
row1.addWidget(b) row1.addWidget(b)
row2 = QHBoxLayout() 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(b)
row2.addWidget(QLabel("Brush")) row2.addWidget(QLabel("Brush"))
row2.addWidget(self.mc.brush_slider) row2.addWidget(self.mc.brush_slider)
@@ -146,7 +154,8 @@ class Annotator(QMainWindow):
btn_skip.clicked.connect(self.skip_clip) btn_skip.clicked.connect(self.skip_clip)
btn_clear.clicked.connect(self.mc.clear) btn_clear.clicked.connect(self.mc.clear)
btn_undo.clicked.connect(self.mc.undo) 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: if self._pending_answers:
self._set_answers(self._pending_answers) self._set_answers(self._pending_answers)
@@ -258,15 +267,6 @@ class Annotator(QMainWindow):
print("Saved:", out) 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): def _switch_ui_to_clip(self):
self.frame_i = 0 self.frame_i = 0
self.mc.load_clip( self.mc.load_clip(

View File

@@ -1,12 +1,13 @@
import numpy as np import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure from matplotlib.figure import Figure
from matplotlib.patches import Circle
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QSlider from PySide6.QtWidgets import QPushButton, QSlider
class MaskCanvas: 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): def __init__(self, frames, dh: int, dw: int):
self.dh = dh self.dh = dh
@@ -14,8 +15,10 @@ class MaskCanvas:
self.mask = np.zeros((dh, dw), dtype=np.uint8) self.mask = np.zeros((dh, dw), dtype=np.uint8)
self.history: list[np.ndarray] = [] self.history: list[np.ndarray] = []
self.redo_stack: list[np.ndarray] = []
self.erase_mode = False self.erase_mode = False
self.drawing = False self.drawing = False
self.mask_visible = True
self._build_figure(frames) self._build_figure(frames)
self._build_controls() self._build_controls()
@@ -29,9 +32,14 @@ class MaskCanvas:
self.img_artist = self.ax.imshow(frames[0]) self.img_artist = self.ax.imshow(frames[0])
self.mask_artist = self.ax.imshow(np.zeros((self.dh, self.dw, 4))) 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.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): def _build_controls(self):
self.btn_erase = QPushButton("Eraser") self.btn_erase = QPushButton("Eraser")
self.btn_mask = QPushButton("Hide Mask")
self.brush_slider = QSlider(Qt.Horizontal) self.brush_slider = QSlider(Qt.Horizontal)
self.brush_slider.setRange(2, 50) self.brush_slider.setRange(2, 50)
self.brush_slider.setValue(5) 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("button_press_event", self._on_press)
self.canvas.mpl_connect("motion_notify_event", self._on_move) self.canvas.mpl_connect("motion_notify_event", self._on_move)
self.canvas.mpl_connect("button_release_event", self._on_release) 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_erase.clicked.connect(self.toggle_erase)
self.btn_mask.clicked.connect(self.toggle_mask)
# ── clip transition ──────────────────────────────────────────── # ── clip transition ────────────────────────────────────────────
def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""):
@@ -48,6 +58,7 @@ class MaskCanvas:
self.dw = dw self.dw = dw
self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8) self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8)
self.history = [] self.history = []
self.redo_stack = []
self.img_artist.set_data(frames[0]) self.img_artist.set_data(frames[0])
self.set_title(title) self.set_title(title)
self.redraw() self.redraw()
@@ -62,14 +73,20 @@ class MaskCanvas:
# ── mask ops ─────────────────────────────────────────────────── # ── mask ops ───────────────────────────────────────────────────
def reset(self, mask=None): 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.history = []
self.redo_stack = []
self.redraw() self.redraw()
def redraw(self): def redraw(self):
rgba = np.zeros((self.dh, self.dw, 4)) if self.mask_visible:
rgba[..., 1] = self.mask * 0.7 rgba = np.zeros((self.dh, self.dw, 4))
rgba[..., 3] = self.mask * 0.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.mask_artist.set_data(rgba)
self.canvas.draw_idle() self.canvas.draw_idle()
@@ -79,17 +96,38 @@ class MaskCanvas:
def undo(self): def undo(self):
if self.history: if self.history:
self.redo_stack.append(self.mask.copy())
self.mask = self.history.pop() self.mask = self.history.pop()
self.redraw() 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): def toggle_erase(self):
self.erase_mode = not self.erase_mode self.erase_mode = not self.erase_mode
self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") 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): def stamp(self, x, y):
if x is None or y is None: if x is None or y is None:
return return
self.history.append(self.mask.copy()) self.history.append(self.mask.copy())
self.redo_stack.clear()
r = self.brush_slider.value() r = self.brush_slider.value()
ix, iy = int(x), int(y) ix, iy = int(x), int(y)
y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1) 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.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1
self.redraw() 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 ─────────────────────────────────────────────── # ── mouse events ───────────────────────────────────────────────
def _on_press(self, e): def _on_press(self, e):
if e.xdata is None: if e.xdata is None:
@@ -107,6 +159,7 @@ class MaskCanvas:
self.stamp(e.xdata, e.ydata) self.stamp(e.xdata, e.ydata)
def _on_move(self, e): def _on_move(self, e):
self._update_brush_preview(e)
if self.drawing: if self.drawing:
self.stamp(e.xdata, e.ydata) self.stamp(e.xdata, e.ydata)