2026-05-20 13:26:03 +02:00
|
|
|
import numpy as np
|
|
|
|
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
|
|
|
from matplotlib.figure import Figure
|
2026-05-20 14:22:58 +02:00
|
|
|
from matplotlib.patches import Circle
|
2026-05-20 13:26:03 +02:00
|
|
|
from PySide6.QtCore import Qt
|
|
|
|
|
from PySide6.QtWidgets import QPushButton, QSlider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MaskCanvas:
|
2026-05-20 14:22:58 +02:00
|
|
|
"""Matplotlib canvas with brush-based mask drawing, undo/redo, and erase."""
|
2026-05-20 13:26:03 +02:00
|
|
|
|
|
|
|
|
def __init__(self, frames, dh: int, dw: int):
|
|
|
|
|
self.dh = dh
|
|
|
|
|
self.dw = dw
|
|
|
|
|
|
|
|
|
|
self.mask = np.zeros((dh, dw), dtype=np.uint8)
|
|
|
|
|
self.history: list[np.ndarray] = []
|
2026-05-20 14:22:58 +02:00
|
|
|
self.redo_stack: list[np.ndarray] = []
|
2026-05-20 13:26:03 +02:00
|
|
|
self.erase_mode = False
|
|
|
|
|
self.drawing = False
|
2026-05-20 14:22:58 +02:00
|
|
|
self.mask_visible = True
|
2026-05-20 13:26:03 +02:00
|
|
|
|
|
|
|
|
self._build_figure(frames)
|
|
|
|
|
self._build_controls()
|
|
|
|
|
self._connect_events()
|
|
|
|
|
|
|
|
|
|
def _build_figure(self, frames):
|
|
|
|
|
self.fig = Figure()
|
|
|
|
|
self.canvas = FigureCanvas(self.fig)
|
|
|
|
|
self.ax = self.fig.add_subplot(111)
|
|
|
|
|
self.ax.axis("off")
|
|
|
|
|
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)
|
2026-05-20 14:22:58 +02:00
|
|
|
self.brush_circle = Circle(
|
|
|
|
|
(0, 0), radius=5, fill=False, color="white", linewidth=1.5, visible=False
|
|
|
|
|
)
|
|
|
|
|
self.ax.add_patch(self.brush_circle)
|
2026-05-20 13:26:03 +02:00
|
|
|
|
|
|
|
|
def _build_controls(self):
|
|
|
|
|
self.btn_erase = QPushButton("Eraser")
|
2026-05-20 14:22:58 +02:00
|
|
|
self.btn_mask = QPushButton("Hide Mask")
|
2026-05-20 13:26:03 +02:00
|
|
|
self.brush_slider = QSlider(Qt.Horizontal)
|
|
|
|
|
self.brush_slider.setRange(2, 50)
|
|
|
|
|
self.brush_slider.setValue(5)
|
|
|
|
|
|
|
|
|
|
def _connect_events(self):
|
|
|
|
|
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)
|
2026-05-20 14:22:58 +02:00
|
|
|
self.canvas.mpl_connect("axes_leave_event", self._on_axes_leave)
|
2026-05-20 13:26:03 +02:00
|
|
|
self.btn_erase.clicked.connect(self.toggle_erase)
|
2026-05-20 14:22:58 +02:00
|
|
|
self.btn_mask.clicked.connect(self.toggle_mask)
|
2026-05-20 13:26:03 +02:00
|
|
|
|
|
|
|
|
# ── clip transition ────────────────────────────────────────────
|
|
|
|
|
def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""):
|
|
|
|
|
self.dh = dh
|
|
|
|
|
self.dw = dw
|
|
|
|
|
self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8)
|
|
|
|
|
self.history = []
|
2026-05-20 14:22:58 +02:00
|
|
|
self.redo_stack = []
|
2026-05-20 13:26:03 +02:00
|
|
|
self.img_artist.set_data(frames[0])
|
|
|
|
|
self.set_title(title)
|
|
|
|
|
self.redraw()
|
|
|
|
|
|
|
|
|
|
# ── frame / title ──────────────────────────────────────────────
|
|
|
|
|
def set_frame(self, frame):
|
|
|
|
|
self.img_artist.set_data(frame)
|
|
|
|
|
self.canvas.draw_idle()
|
|
|
|
|
|
|
|
|
|
def set_title(self, text: str):
|
|
|
|
|
self.title_text.set_text(text)
|
|
|
|
|
|
|
|
|
|
# ── mask ops ───────────────────────────────────────────────────
|
|
|
|
|
def reset(self, mask=None):
|
2026-05-20 14:22:58 +02:00
|
|
|
self.mask = (
|
|
|
|
|
mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8)
|
|
|
|
|
)
|
2026-05-20 13:26:03 +02:00
|
|
|
self.history = []
|
2026-05-20 14:22:58 +02:00
|
|
|
self.redo_stack = []
|
2026-05-20 13:26:03 +02:00
|
|
|
self.redraw()
|
|
|
|
|
|
|
|
|
|
def redraw(self):
|
2026-05-20 14:22:58 +02:00
|
|
|
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))
|
2026-05-20 13:26:03 +02:00
|
|
|
self.mask_artist.set_data(rgba)
|
|
|
|
|
self.canvas.draw_idle()
|
|
|
|
|
|
|
|
|
|
def clear(self):
|
|
|
|
|
self.mask[:] = 0
|
|
|
|
|
self.redraw()
|
|
|
|
|
|
|
|
|
|
def undo(self):
|
|
|
|
|
if self.history:
|
2026-05-20 14:22:58 +02:00
|
|
|
self.redo_stack.append(self.mask.copy())
|
2026-05-20 13:26:03 +02:00
|
|
|
self.mask = self.history.pop()
|
|
|
|
|
self.redraw()
|
|
|
|
|
|
2026-05-20 14:22:58 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-05-20 13:26:03 +02:00
|
|
|
def toggle_erase(self):
|
|
|
|
|
self.erase_mode = not self.erase_mode
|
|
|
|
|
self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser")
|
|
|
|
|
|
2026-05-20 14:22:58 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-05-20 13:26:03 +02:00
|
|
|
def stamp(self, x, y):
|
|
|
|
|
if x is None or y is None:
|
|
|
|
|
return
|
|
|
|
|
self.history.append(self.mask.copy())
|
2026-05-20 14:22:58 +02:00
|
|
|
self.redo_stack.clear()
|
2026-05-20 13:26:03 +02:00
|
|
|
r = self.brush_slider.value()
|
|
|
|
|
ix, iy = int(x), int(y)
|
|
|
|
|
y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1)
|
|
|
|
|
x0, x1 = max(0, ix - r), min(self.dw, ix + r + 1)
|
|
|
|
|
Y, X = np.ogrid[y0:y1, x0:x1]
|
|
|
|
|
circle = (X - ix) ** 2 + (Y - iy) ** 2 <= r**2
|
|
|
|
|
self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1
|
|
|
|
|
self.redraw()
|
|
|
|
|
|
2026-05-20 14:22:58 +02:00
|
|
|
# ── 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()
|
|
|
|
|
|
2026-05-20 13:26:03 +02:00
|
|
|
# ── mouse events ───────────────────────────────────────────────
|
|
|
|
|
def _on_press(self, e):
|
|
|
|
|
if e.xdata is None:
|
|
|
|
|
return
|
|
|
|
|
self.drawing = True
|
|
|
|
|
self.stamp(e.xdata, e.ydata)
|
|
|
|
|
|
|
|
|
|
def _on_move(self, e):
|
2026-05-20 14:22:58 +02:00
|
|
|
self._update_brush_preview(e)
|
2026-05-20 13:26:03 +02:00
|
|
|
if self.drawing:
|
|
|
|
|
self.stamp(e.xdata, e.ydata)
|
|
|
|
|
|
|
|
|
|
def _on_release(self, _):
|
|
|
|
|
self.drawing = False
|