| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import os, sys, cv2, numpy as np |
| from dataclasses import dataclass, field |
| from typing import List, Optional, Tuple, Dict |
| import shutil, subprocess |
|
|
|
|
| from PySide6 import QtCore, QtGui, QtWidgets |
| from PySide6.QtCore import Qt, QPointF, Signal |
| from PySide6.QtGui import ( |
| QImage, QPixmap, QPainterPath, QPen, QColor, QPainter, QPolygonF, QIcon |
| ) |
| from PySide6.QtWidgets import ( |
| QApplication, QMainWindow, QFileDialog, QGraphicsView, QGraphicsScene, |
| QGraphicsPixmapItem, QGraphicsPathItem, QGraphicsLineItem, |
| QToolBar, QLabel, QSpinBox, QWidget, QMessageBox, |
| QComboBox, QPushButton, QGraphicsEllipseItem, QFrame, QVBoxLayout, QSlider, QHBoxLayout, |
| QPlainTextEdit |
| ) |
| import imageio |
|
|
| |
| |
| |
|
|
| def np_bgr_to_qpixmap(img_bgr: np.ndarray) -> QPixmap: |
| h, w = img_bgr.shape[:2] |
| img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) |
| qimg = QImage(img_rgb.data, w, h, img_rgb.strides[0], QImage.Format.Format_RGB888) |
| return QPixmap.fromImage(qimg.copy()) |
|
|
| def np_rgba_to_qpixmap(img_rgba: np.ndarray) -> QPixmap: |
| h, w = img_rgba.shape[:2] |
| qimg = QImage(img_rgba.data, w, h, img_rgba.strides[0], QImage.Format.Format_RGBA8888) |
| return QPixmap.fromImage(qimg.copy()) |
|
|
| |
| |
| |
|
|
| def load_first_frame(path: str) -> np.ndarray: |
| if not os.path.exists(path): |
| raise FileNotFoundError(path) |
| low = path.lower() |
| if low.endswith((".mp4", ".mov", ".avi", ".mkv")): |
| cap = cv2.VideoCapture(path) |
| ok, frame = cap.read() |
| cap.release() |
| if not ok: |
| raise RuntimeError("Failed to read first frame from video") |
| return frame |
| img = cv2.imread(path, cv2.IMREAD_COLOR) |
| if img is None: |
| raise RuntimeError("Failed to read image") |
| return img |
|
|
| def resize_then_center_crop(img: np.ndarray, target_h: int, target_w: int, interpolation=cv2.INTER_NEAREST) -> np.ndarray: |
| h, w = img.shape[:2] |
| scale = max(target_w / float(w), target_h / float(h)) |
| new_w, new_h = int(round(w * scale)), int(round(h * scale)) |
| resized = cv2.resize(img, (new_w, new_h), interpolation=interpolation) |
| y0 = (new_h - target_h) // 2 |
| x0 = (new_w - target_w) // 2 |
| return resized[y0:y0 + target_h, x0:x0 + target_w] |
|
|
| def fit_center_pad(img: np.ndarray, target_h: int, target_w: int, interpolation=cv2.INTER_NEAREST) -> np.ndarray: |
| h, w = img.shape[:2] |
| scale_h = target_h / float(h) |
| new_w_hfirst = int(round(w * scale_h)) |
| new_h_hfirst = target_h |
| if new_w_hfirst <= target_w: |
| resized = cv2.resize(img, (new_w_hfirst, new_h_hfirst), interpolation=interpolation) |
| result = np.zeros((target_h, target_w, 3), dtype=np.uint8) |
| x0 = (target_w - new_w_hfirst) // 2 |
| result[:, x0:x0 + new_w_hfirst] = resized |
| return result |
| scale_w = target_w / float(w) |
| new_w_wfirst = target_w |
| new_h_wfirst = int(round(h * scale_w)) |
| resized = cv2.resize(img, (new_w_wfirst, new_h_wfirst), interpolation=interpolation) |
| result = np.zeros((target_h, target_w, 3), dtype=np.uint8) |
| y0 = (target_h - new_h_wfirst) // 2 |
| result[y0:y0 + new_h_wfirst, :] = resized |
| return result |
|
|
| |
| |
| |
|
|
| def apply_hue_shift_bgr(img_bgr: np.ndarray, hue_deg: float) -> np.ndarray: |
| """Rotate hue by hue_deg (degrees) in HSV space. S and V unchanged.""" |
| if abs(hue_deg) < 1e-6: |
| return img_bgr.copy() |
| hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) |
| h = hsv[:, :, 0].astype(np.int16) |
| offset = int(round((hue_deg / 360.0) * 179.0)) |
| h = (h + offset) % 180 |
| hsv[:, :, 0] = h.astype(np.uint8) |
| return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) |
|
|
| |
| |
| |
|
|
| def alpha_over(bg_bgr: np.ndarray, fg_rgba: np.ndarray) -> np.ndarray: |
| a = (fg_rgba[:, :, 3:4].astype(np.float32) / 255.0) |
| if a.max() == 0: |
| return bg_bgr.copy() |
| fg = fg_rgba[:, :, :3].astype(np.float32) |
| bg = bg_bgr.astype(np.float32) |
| out = fg * a + bg * (1.0 - a) |
| return np.clip(out, 0, 255).astype(np.uint8) |
|
|
| def inpaint_background(image_bgr: np.ndarray, mask_bool: np.ndarray) -> np.ndarray: |
| mask = (mask_bool.astype(np.uint8) * 255) |
| return cv2.inpaint(image_bgr, mask, 3, cv2.INPAINT_TELEA) |
|
|
| def animate_polygon(image_bgr, polygon_xy, path_xy, scales, rotations_deg, interp=cv2.INTER_LINEAR, origin_xy=None): |
| """ |
| Returns list of RGBA frames and list of transformed polygons per frame. |
| Uses BORDER_REPLICATE so off-canvas doesn't appear black. |
| """ |
| h, w = image_bgr.shape[:2] |
| frames_rgba = [] |
| polys_per_frame = [] |
|
|
| if origin_xy is None: |
| if len(path_xy) == 0: |
| raise ValueError("animate_polygon: path_xy is empty and origin_xy not provided.") |
| origin = np.asarray(path_xy[0], dtype=np.float32) |
| else: |
| origin = np.asarray(origin_xy, dtype=np.float32) |
|
|
| for i in range(len(path_xy)): |
| theta = np.deg2rad(rotations_deg[i]).astype(np.float32) |
| s = float(scales[i]) |
| a11 = s * np.cos(theta); a12 = -s * np.sin(theta) |
| a21 = s * np.sin(theta); a22 = s * np.cos(theta) |
| tx = path_xy[i, 0] - (a11 * origin[0] + a12 * origin[1]) |
| ty = path_xy[i, 1] - (a21 * origin[0] + a22 * origin[1]) |
| M = np.array([[a11, a12, tx], [a21, a22, ty]], dtype=np.float32) |
|
|
| warped = cv2.warpAffine(image_bgr, M, (w, h), flags=interp, |
| borderMode=cv2.BORDER_REPLICATE) |
|
|
| poly = np.asarray(polygon_xy, dtype=np.float32) |
| pts1 = np.hstack([poly, np.ones((len(poly), 1), dtype=np.float32)]) |
| poly_t = (M @ pts1.T).T |
| polys_per_frame.append(poly_t.astype(np.float32)) |
|
|
| mask = np.zeros((h, w), dtype=np.uint8) |
| cv2.fillPoly(mask, [poly_t.astype(np.int32)], 255) |
|
|
| rgba = np.zeros((h, w, 4), dtype=np.uint8) |
| rgba[:, :, :3] = warped |
| rgba[:, :, 3] = mask |
| frames_rgba.append(rgba) |
|
|
| return frames_rgba, polys_per_frame |
|
|
| def composite_frames(background_bgr, list_of_layer_frame_lists): |
| frames = [] |
| T = len(list_of_layer_frame_lists[0]) if list_of_layer_frame_lists else 0 |
| for t in range(T): |
| frame = background_bgr.copy() |
| for layer in list_of_layer_frame_lists: |
| frame = alpha_over(frame, layer[t]) |
| frames.append(frame) |
| return frames |
|
|
| def save_video_mp4(frames_bgr, path, fps=24): |
| """ |
| Write MP4 using imageio (FFmpeg backend) with H.264 + yuv420p so it works on macOS/QuickTime. |
| - Converts BGR->RGB (imageio expects RGB) |
| - Enforces even width/height (needed for yuv420p) |
| - Tags BT.709 and faststart for smooth playback |
| """ |
| if not frames_bgr: |
| raise ValueError("No frames to save") |
|
|
| |
| h, w = frames_bgr[0].shape[:2] |
| out_frames = [] |
| for f in frames_bgr: |
| if f is None: |
| raise RuntimeError("Encountered None frame") |
| |
| if f.ndim == 2: |
| f = cv2.cvtColor(f, cv2.COLOR_GRAY2BGR) |
| elif f.shape[2] == 4: |
| f = cv2.cvtColor(f, cv2.COLOR_BGRA2BGR) |
| elif f.shape[2] != 3: |
| raise RuntimeError("Frames must be gray, BGR, or BGRA") |
| if f.shape[:2] != (h, w): |
| raise RuntimeError("Frame size mismatch during save.") |
| if f.dtype != np.uint8: |
| f = np.clip(f, 0, 255).astype(np.uint8) |
| |
| out_frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2RGB)) |
|
|
| |
| hh = h - (h % 2) |
| ww = w - (w % 2) |
| if (hh != h) or (ww != w): |
| out_frames = [frm[:hh, :ww] for frm in out_frames] |
| h, w = hh, ww |
|
|
| |
| ffmpeg_common = ['-movflags', '+faststart', |
| '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', |
| '-tag:v', 'avc1'] |
| try: |
| writer = imageio.get_writer( |
| path, format='ffmpeg', fps=float(fps), |
| codec='libx264', pixelformat='yuv420p', |
| ffmpeg_params=ffmpeg_common |
| ) |
| except Exception: |
| |
| writer = imageio.get_writer( |
| path, format='ffmpeg', fps=float(fps), |
| codec='mpeg4', pixelformat='yuv420p', |
| ffmpeg_params=['-movflags', '+faststart'] |
| ) |
|
|
| try: |
| for frm in out_frames: |
| writer.append_data(frm) |
| finally: |
| writer.close() |
|
|
| if not os.path.exists(path) or os.path.getsize(path) == 0: |
| raise RuntimeError("imageio/ffmpeg produced an empty file. Check that FFmpeg is available.") |
| return path |
|
|
|
|
|
|
| |
| |
| |
|
|
| PALETTE = [ |
| QColor(255, 99, 99), |
| QColor(99, 155, 255), |
| QColor(120, 220, 120), |
| QColor(255, 200, 80), |
| QColor(200, 120, 255), |
| QColor(120, 255, 255) |
| ] |
|
|
| @dataclass |
| class Keyframe: |
| pos: np.ndarray |
| rot_deg: float |
| scale: float |
| hue_deg: float = 0.0 |
|
|
| @dataclass |
| class Layer: |
| name: str |
| source_bgr: np.ndarray |
| polygon_xy: Optional[np.ndarray] = None |
| origin_local_xy: Optional[np.ndarray] = None |
| is_external: bool = False |
| pixmap_item: Optional[QtWidgets.QGraphicsPixmapItem] = None |
| outline_item: Optional[QGraphicsPathItem] = None |
| handle_items: List[QtWidgets.QGraphicsItem] = field(default_factory=list) |
| keyframes: List[Keyframe] = field(default_factory=list) |
| path_lines: List[QGraphicsLineItem] = field(default_factory=list) |
| preview_line: Optional[QGraphicsLineItem] = None |
| color: QColor = field(default_factory=lambda: QColor(255, 99, 99)) |
|
|
| def has_polygon(self) -> bool: |
| return self.polygon_xy is not None and len(self.polygon_xy) >= 3 |
|
|
| |
| |
| |
|
|
| class HandleBase(QGraphicsEllipseItem): |
| def __init__(self, r: float, color: QColor, parent=None): |
| super().__init__(-r, -r, 2*r, 2*r, parent) |
| self.setBrush(color) |
| pen = QPen(QColor(0, 0, 0), 1) |
| pen.setCosmetic(True) |
| self.setPen(pen) |
| self.setFlag(QGraphicsEllipseItem.ItemIsMovable, False) |
| self.setAcceptHoverEvents(True) |
| self.setZValue(2000) |
| self._item: Optional[QGraphicsPixmapItem] = None |
|
|
| |
| self.setFlag(QGraphicsEllipseItem.ItemIgnoresTransformations, True) |
|
|
| def set_item(self, item: QGraphicsPixmapItem): |
| self._item = item |
|
|
| def origin_scene(self) -> QPointF: |
| return self._item.mapToScene(self._item.transformOriginPoint()) |
|
|
| class ScaleHandle(HandleBase): |
| def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent): |
| if not self._item: return super().mousePressEvent(event) |
| self._start_scale = self._item.scale() if self._item.scale() != 0 else 1.0 |
| self._origin_scene = self.origin_scene() |
| v0 = event.scenePos() - self._origin_scene |
| self._d0 = max(1e-6, (v0.x()*v0.x() + v0.y()*v0.y())**0.5) |
| event.accept() |
| def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent): |
| if not self._item: return super().mouseMoveEvent(event) |
| v = event.scenePos() - self._origin_scene |
| d = max(1e-6, (v.x()*v.x() + v.y()*v.y())**0.5) |
| s = float(self._start_scale * (d / self._d0)) |
| s = float(np.clip(s, 0.05, 10.0)) |
| self._item.setScale(s) |
| event.accept() |
|
|
| class RotateHandle(HandleBase): |
| def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent): |
| if not self._item: return super().mousePressEvent(event) |
| self._start_rot = self._item.rotation() |
| self._origin_scene = self.origin_scene() |
| v0 = event.scenePos() - self._origin_scene |
| self._a0 = np.degrees(np.arctan2(v0.y(), v0.x())) |
| event.accept() |
| def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent): |
| if not self._item: return super().mouseMoveEvent(event) |
| v = event.scenePos() - self._origin_scene |
| a = np.degrees(np.arctan2(v.y(), v.x())) |
| delta = a - self._a0 |
| r = self._start_rot + delta |
| if r > 180: r -= 360 |
| if r < -180: r += 360 |
| self._item.setRotation(r) |
| event.accept() |
|
|
| |
| |
| |
|
|
| class NotifyingPixmapItem(QGraphicsPixmapItem): |
| def __init__(self, pm: QPixmap, on_change_cb=None): |
| super().__init__(pm) |
| self._on_change_cb = on_change_cb |
| self.setFlag(QGraphicsPixmapItem.ItemSendsGeometryChanges, True) |
| def itemChange(self, change, value): |
| ret = super().itemChange(change, value) |
| if change in (QGraphicsPixmapItem.ItemPositionHasChanged, |
| QGraphicsPixmapItem.ItemRotationHasChanged, |
| QGraphicsPixmapItem.ItemScaleHasChanged): |
| if callable(self._on_change_cb): |
| self._on_change_cb() |
| return ret |
|
|
| |
| |
| |
|
|
| class Canvas(QGraphicsView): |
| MODE_IDLE = 0 |
| MODE_DRAW_POLY = 1 |
|
|
| polygon_finished = Signal(bool) |
| end_segment_requested = Signal() |
|
|
| def __init__(self, parent=None): |
| super().__init__(parent) |
| self.setRenderHint(QtGui.QPainter.Antialiasing, True) |
| self.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, False) |
| self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) |
| self.setDragMode(QGraphicsView.NoDrag) |
| self.scene = QGraphicsScene(self) |
| self.setScene(self.scene) |
|
|
| self.base_bgr = None |
| self.base_preview_bgr = None |
| self.base_item = None |
| self.layers: List[Layer] = [] |
| self.current_layer: Optional[Layer] = None |
| self.layer_index = 0 |
|
|
| self.mode = Canvas.MODE_IDLE |
| self.temp_points: List[QPointF] = [] |
| self.temp_path_item: Optional[QGraphicsPathItem] = None |
| self.first_click_marker: Optional[QGraphicsEllipseItem] = None |
|
|
| self.fit_mode_combo = None |
| self.target_w = 720 |
| self.target_h = 480 |
|
|
| |
| self.current_segment_hue_deg: float = 0.0 |
|
|
| |
| self.play_timer = QtCore.QTimer(self) |
| self.play_timer.timeout.connect(self._on_play_tick) |
| self.play_frames: List[np.ndarray] = [] |
| self.play_index = 0 |
| self.player_item: Optional[QGraphicsPixmapItem] = None |
|
|
| self.setMouseTracking(True) |
| self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) |
| self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) |
|
|
|
|
| |
| def _remove_if_in_scene(self, item): |
| if item is None: |
| return |
| try: |
| sc = item.scene() |
| if sc is None: |
| return |
| if sc is self.scene: |
| self.scene.removeItem(item) |
| else: |
| |
| sc.removeItem(item) |
| except Exception: |
| pass |
|
|
|
|
| def _apply_pose_from_origin_scene(self, item, origin_scene_qp: QPointF, rot: float, scale: float): |
| item.setRotation(float(rot)) |
| item.setScale(float(scale) if scale != 0 else 1.0) |
| new_origin = item.mapToScene(item.transformOriginPoint()) |
| d = origin_scene_qp - new_origin |
| item.setPos(item.pos() + d) |
|
|
| |
| def make_pentagon_icon(self) -> QIcon: |
| pm = QPixmap(22, 22) |
| pm.fill(Qt.GlobalColor.transparent) |
| p = QPainter(pm) |
| p.setRenderHint(QPainter.Antialiasing, True) |
| pen = QPen(QColor(40, 40, 40)); pen.setWidth(2) |
| p.setPen(pen) |
| r = 8; cx, cy = 11, 11 |
| pts = [] |
| for i in range(5): |
| ang = -90 + i * 72 |
| rad = np.radians(ang) |
| pts.append(QPointF(cx + r * np.cos(rad), cy + r * np.sin(rad))) |
| p.drawPolygon(QPolygonF(pts)) |
| p.end() |
| return QIcon(pm) |
|
|
| |
| def _apply_fit(self, img: np.ndarray) -> np.ndarray: |
| mode = 'Center Crop' |
| if self.fit_mode_combo is not None: |
| mode = self.fit_mode_combo.currentText() |
| if mode == 'Center Pad': |
| return fit_center_pad(img, self.target_h, self.target_w, interpolation=cv2.INTER_NEAREST) |
| else: |
| return resize_then_center_crop(img, self.target_h, self.target_w, interpolation=cv2.INTER_NEAREST) |
|
|
| def _refresh_inpaint_preview(self): |
| if self.base_bgr is None: |
| return |
| H, W = self.base_bgr.shape[:2] |
| total_mask = np.zeros((H, W), dtype=bool) |
| for L in self.layers: |
| if not L.has_polygon() or L.is_external: |
| continue |
| poly0 = L.polygon_xy.astype(np.int32) |
| mask = np.zeros((H, W), dtype=np.uint8) |
| cv2.fillPoly(mask, [poly0], 255) |
| total_mask |= (mask > 0) |
| inpainted = inpaint_background(self.base_bgr, total_mask) |
| self.base_preview_bgr = inpainted.copy() |
| if self.base_item is not None: |
| self.base_item.setPixmap(np_bgr_to_qpixmap(self.base_preview_bgr)) |
|
|
| |
| def _expand_scene_to_item(self, item: QtWidgets.QGraphicsItem, margin: int = 120, center: bool = True): |
| if item is None: |
| return |
| try: |
| local_rect = item.boundingRect() |
| poly = item.mapToScene(local_rect) |
| r = poly.boundingRect() |
| except Exception: |
| r = self.scene.sceneRect() |
| sr = self.scene.sceneRect().united(r.adjusted(-margin, -margin, margin, margin)) |
| self.scene.setSceneRect(sr) |
| self.ensureVisible(r.adjusted(-20, -20, 20, 20)) |
| if center: |
| try: |
| self.centerOn(item) |
| except Exception: |
| pass |
|
|
| |
| def set_base_image(self, bgr_original: np.ndarray): |
| self.scene.clear() |
| for L in self.layers: |
| L.handle_items.clear(); L.path_lines.clear(); L.preview_line = None |
| self.layers.clear(); self.current_layer = None |
| base_for_save = resize_then_center_crop(bgr_original, self.target_h, self.target_w, interpolation=cv2.INTER_AREA) |
| self.base_bgr = base_for_save.copy() |
| self.base_preview_bgr = self._apply_fit(bgr_original) |
| pm = np_bgr_to_qpixmap(self.base_preview_bgr) |
| self.base_item = self.scene.addPixmap(pm) |
| self.base_item.setZValue(0) |
| self.base_item.setTransformationMode(Qt.FastTransformation) |
| self.setSceneRect(0, 0, pm.width(), pm.height()) |
|
|
| |
| def add_external_sprite_layer(self, raw_bgr: np.ndarray) -> 'Layer': |
| if self.base_bgr is None: |
| return None |
| H, W = self.base_bgr.shape[:2] |
| h0, w0 = raw_bgr.shape[:2] |
| target_h = int(0.6 * H) |
| scale = target_h / float(h0) |
| ew = int(round(w0 * scale)) |
| eh = int(round(h0 * scale)) |
| ext_small = cv2.resize(raw_bgr, (ew, eh), interpolation=cv2.INTER_AREA) |
|
|
| |
| px = (W - ew) // 2 |
| py = (H - eh) // 2 |
| source_bgr = np.zeros((H, W, 3), dtype=np.uint8) |
| x0 = max(px, 0); y0 = max(py, 0) |
| x1 = min(px + ew, W); y1 = min(py + eh, H) |
| if x0 < x1 and y0 < y1: |
| sx0 = x0 - px; sy0 = y0 - py |
| sx1 = sx0 + (x1 - x0); sy1 = sy0 + (y1 - y0) |
| source_bgr[y0:y1, x0:x1] = ext_small[sy0:sy1, sx0:sx1] |
|
|
| rect_poly = np.array([[px, py], [px+ew, py], [px+ew, py+eh], [px, py+eh]], dtype=np.float32) |
| cx, cy = px + ew/2.0, py + eh/2.0 |
|
|
| mask_rect = np.zeros((H, W), dtype=np.uint8) |
| cv2.fillPoly(mask_rect, [rect_poly.astype(np.int32)], 255) |
| rgba = np.dstack([cv2.cvtColor(source_bgr, cv2.COLOR_BGR2RGB), mask_rect]) |
| pm = np_rgba_to_qpixmap(rgba) |
|
|
| color = PALETTE[self.layer_index % len(PALETTE)]; self.layer_index += 1 |
| L = Layer(name=f"Layer {len(self.layers)+1} (ext)", source_bgr=source_bgr, is_external=True, |
| polygon_xy=rect_poly.copy(), origin_local_xy=np.array([cx, cy], dtype=np.float32), color=color) |
| self.layers.append(L); self.current_layer = L |
|
|
| def on_change(): |
| if L.keyframes: |
| self._ensure_preview_line(L) |
| self._relayout_handles(L) |
|
|
| item = NotifyingPixmapItem(pm, on_change_cb=on_change) |
| item.setZValue(10 + len(self.layers)) |
| item.setFlag(QGraphicsPixmapItem.ItemIsMovable, True) |
| item.setFlag(QGraphicsPixmapItem.ItemIsSelectable, False) |
| item.setTransformationMode(Qt.FastTransformation) |
| item.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape) |
| item.setTransformOriginPoint(QPointF(cx, cy)) |
| self.scene.addItem(item); L.pixmap_item = item |
|
|
| |
| min_vis = min(max(40, ew // 5), W // 2) |
| outside_x = (min_vis - (px + ew)) |
| item.setPos(outside_x, 0) |
|
|
| |
| qpath = QPainterPath(QPointF(rect_poly[0,0], rect_poly[0,1])) |
| for i in range(1, len(rect_poly)): qpath.lineTo(QPointF(rect_poly[i,0], rect_poly[i,1])) |
| qpath.closeSubpath() |
| outline = QGraphicsPathItem(qpath, parent=item) |
| outline.setPen(QPen(L.color, 2, Qt.DashLine)) |
| outline.setZValue(item.zValue() + 1) |
| L.outline_item = outline |
| self._create_handles_for_layer(L) |
|
|
| self._expand_scene_to_item(item, center=True) |
| return L |
|
|
| def place_external_initial_keyframe(self, L: 'Layer'): |
| if not (L and L.pixmap_item): return |
| origin_scene = L.pixmap_item.mapToScene(L.pixmap_item.transformOriginPoint()) |
| L.keyframes.append(Keyframe( |
| pos=np.array([origin_scene.x(), origin_scene.y()], dtype=np.float32), |
| rot_deg=float(L.pixmap_item.rotation()), |
| scale=float(L.pixmap_item.scale()) if L.pixmap_item.scale()!=0 else 1.0, |
| hue_deg=0.0 |
| )) |
| self._ensure_preview_line(L) |
|
|
| |
| def new_layer_from_source(self, name: str, source_bgr: np.ndarray, is_external: bool): |
| color = PALETTE[self.layer_index % len(PALETTE)]; self.layer_index += 1 |
| layer = Layer(name=name, source_bgr=source_bgr.copy(), is_external=is_external, color=color) |
| self.layers.append(layer); self.current_layer = layer |
| self.start_draw_polygon(preserve_motion=False) |
|
|
| def start_draw_polygon(self, preserve_motion: bool): |
| L = self.current_layer |
| if L is None: return |
| if preserve_motion: |
| for it in L.handle_items: |
| it.setVisible(False) |
| else: |
| |
| |
| if L.outline_item is not None: |
| self._remove_if_in_scene(L.outline_item) |
| L.outline_item = None |
| for it in L.handle_items: |
| self._remove_if_in_scene(it) |
| L.handle_items = [] |
| if L.preview_line is not None: |
| self._remove_if_in_scene(L.preview_line) |
| L.preview_line = None |
| if L.pixmap_item is not None: |
| self._remove_if_in_scene(L.pixmap_item) |
| L.pixmap_item = None |
|
|
| L.path_lines = [] |
| L.keyframes.clear() |
| L.polygon_xy = None |
| self.mode = Canvas.MODE_DRAW_POLY |
| self.temp_points = [] |
| if self.temp_path_item is not None: |
| self.scene.removeItem(self.temp_path_item); self.temp_path_item = None |
| if self.first_click_marker is not None: |
| self.scene.removeItem(self.first_click_marker); self.first_click_marker = None |
| |
| self.current_segment_hue_deg = 0.0 |
|
|
| def _compute_ext_rect_from_source(self, src_bgr: np.ndarray) -> np.ndarray: |
| ys, xs = np.where(np.any(src_bgr != 0, axis=2)) |
| if len(xs) == 0 or len(ys) == 0: |
| return np.array([[0,0],[0,0],[0,0],[0,0]], dtype=np.float32) |
| x0, x1 = int(xs.min()), int(xs.max()) |
| y0, y1 = int(ys.min()), int(ys.max()) |
| return np.array([[x0,y0],[x1,y0],[x1,y1],[x0,y1]], dtype=np.float32) |
|
|
| def _make_rgba_from_bgr_and_maskpoly(self, bgr: np.ndarray, poly: np.ndarray) -> np.ndarray: |
| H, W = bgr.shape[:2] |
| mask = np.zeros((H, W), dtype=np.uint8) |
| if poly is not None and poly.size: |
| cv2.fillPoly(mask, [poly.astype(np.int32)], 255) |
| rgba = np.dstack([cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB), mask]) |
| return rgba |
|
|
| def _add_static_external_item(self, bgr_inpainted: np.ndarray, rect_poly: np.ndarray, |
| kf0: 'Keyframe', z_under: float, color: QColor) -> QGraphicsPixmapItem: |
| rgba = self._make_rgba_from_bgr_and_maskpoly(bgr_inpainted, rect_poly) |
| pm = np_rgba_to_qpixmap(rgba) |
| item = QGraphicsPixmapItem(pm) |
| item.setZValue(max(1.0, z_under - 0.2)) |
| item.setTransformationMode(Qt.FastTransformation) |
| item.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape) |
| cx = (rect_poly[:,0].min() + rect_poly[:,0].max())/2.0 |
| cy = (rect_poly[:,1].min() + rect_poly[:,1].max())/2.0 |
| item.setTransformOriginPoint(QPointF(cx, cy)) |
| self.scene.addItem(item) |
| self._apply_pose_from_origin_scene(item, QPointF(kf0.pos[0], kf0.pos[1]), kf0.rot_deg, kf0.scale) |
| path = QPainterPath(QPointF(rect_poly[0,0], rect_poly[0,1])) |
| for i in range(1, len(rect_poly)): path.lineTo(QPointF(rect_poly[i,0], rect_poly[i,1])) |
| path.closeSubpath() |
| outline = QGraphicsPathItem(path, parent=item) |
| outline.setPen(QPen(color, 1, Qt.DashLine)) |
| outline.setZValue(item.zValue() + 0.01) |
| self._expand_scene_to_item(item, center=False) |
| return item |
|
|
| def _update_current_item_hue_preview(self): |
| """Live hue preview for current moving polygon sprite.""" |
| L = self.current_layer |
| if not (L and L.pixmap_item and L.polygon_xy is not None): |
| return |
| |
| bgr = L.source_bgr |
| if abs(self.current_segment_hue_deg) > 1e-6: |
| bgr = apply_hue_shift_bgr(bgr, self.current_segment_hue_deg) |
| rgba = self._make_rgba_from_bgr_and_maskpoly(bgr, L.polygon_xy) |
| L.pixmap_item.setPixmap(np_rgba_to_qpixmap(rgba)) |
|
|
| def finish_polygon(self, preserve_motion: bool) -> bool: |
| L = self.current_layer |
| if L is None or self.mode != Canvas.MODE_DRAW_POLY: return False |
| if len(self.temp_points) < 3: return False |
|
|
| pts_scene = [QtCore.QPointF(p) for p in self.temp_points] |
|
|
| if preserve_motion and L.pixmap_item is not None and L.is_external: |
| |
| old_item = L.pixmap_item |
|
|
| |
| pts_local_qt = [old_item.mapFromScene(p) for p in pts_scene] |
| pts_local = np.array([[p.x(), p.y()] for p in pts_local_qt], dtype=np.float32) |
|
|
| |
| x0, y0 = pts_local.min(axis=0) |
| x1, y1 = pts_local.max(axis=0) |
| cx_local, cy_local = (x0 + x1) / 2.0, (y0 + y1) / 2.0 |
|
|
| rect_poly_prev = (L.polygon_xy.copy() |
| if (L.polygon_xy is not None and len(L.polygon_xy) >= 3) |
| else self._compute_ext_rect_from_source(L.source_bgr)) |
|
|
| |
| old_origin_scene = old_item.mapToScene(old_item.transformOriginPoint()) |
| old_rot = old_item.rotation() |
| old_scale = old_item.scale() if old_item.scale() != 0 else 1.0 |
| old_z = old_item.zValue() |
|
|
| |
| H, W = L.source_bgr.shape[:2] |
| rgb_full = cv2.cvtColor(L.source_bgr, cv2.COLOR_BGR2RGB) |
|
|
| mov_mask = np.zeros((H, W), dtype=np.uint8); cv2.fillPoly(mov_mask, [pts_local.astype(np.int32)], 255) |
| mov_rgba = np.dstack([rgb_full, mov_mask]) |
|
|
| hole_mask = np.zeros((H, W), dtype=np.uint8); cv2.fillPoly(hole_mask, [pts_local.astype(np.int32)], 255) |
| inpainted_ext = inpaint_background(L.source_bgr, hole_mask > 0) |
| rect_mask = np.zeros((H, W), dtype=np.uint8); cv2.fillPoly(rect_mask, [rect_poly_prev.astype(np.int32)], 255) |
| static_rgba = np.dstack([cv2.cvtColor(inpainted_ext, cv2.COLOR_BGR2RGB), rect_mask]) |
|
|
| |
| if L.outline_item is not None: |
| self._remove_if_in_scene(L.outline_item); L.outline_item = None |
| for it in L.handle_items: |
| self._remove_if_in_scene(it) |
| L.handle_items = [] |
| self._remove_if_in_scene(old_item) |
|
|
| |
| kf0 = L.keyframes[0] if L.keyframes else Keyframe( |
| pos=np.array([old_origin_scene.x(), old_origin_scene.y()], dtype=np.float32), |
| rot_deg=old_rot, scale=old_scale, hue_deg=0.0 |
| ) |
| static_item = QGraphicsPixmapItem(np_rgba_to_qpixmap(static_rgba)) |
| static_item.setZValue(max(1.0, old_z - 0.2)) |
| static_item.setTransformationMode(Qt.FastTransformation) |
| static_item.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape) |
| rcx = (rect_poly_prev[:,0].min() + rect_poly_prev[:,0].max())/2.0 |
| rcy = (rect_poly_prev[:,1].min() + rect_poly_prev[:,1].max())/2.0 |
| static_item.setTransformOriginPoint(QPointF(rcx, rcy)) |
| self.scene.addItem(static_item) |
| self._apply_pose_from_origin_scene(static_item, QPointF(kf0.pos[0], kf0.pos[1]), kf0.rot_deg, kf0.scale) |
| |
| qpath_rect = QPainterPath(QPointF(rect_poly_prev[0,0], rect_poly_prev[0,1])) |
| for i in range(1, len(rect_poly_prev)): qpath_rect.lineTo(QPointF(rect_poly_prev[i,0], rect_poly_prev[i,1])) |
| qpath_rect.closeSubpath() |
| outline_static = QGraphicsPathItem(qpath_rect, parent=static_item) |
| outline_static.setPen(QPen(L.color, 1, Qt.DashLine)) |
| outline_static.setZValue(static_item.zValue() + 0.01) |
|
|
| |
| def on_change(): |
| if L.keyframes: |
| self._ensure_preview_line(L) |
| self._relayout_handles(L) |
|
|
| poly_item = NotifyingPixmapItem(np_rgba_to_qpixmap(mov_rgba), on_change_cb=on_change) |
| poly_item.setZValue(old_z + 0.2) |
| poly_item.setFlag(QGraphicsPixmapItem.ItemIsMovable, True) |
| poly_item.setFlag(QGraphicsPixmapItem.ItemIsSelectable, False) |
| poly_item.setTransformationMode(Qt.FastTransformation) |
| poly_item.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape) |
| poly_item.setTransformOriginPoint(QPointF(cx_local, cy_local)) |
| self.scene.addItem(poly_item) |
| self._apply_pose_from_origin_scene(poly_item, old_origin_scene, old_rot, old_scale) |
|
|
| |
| qpath = QPainterPath(QPointF(pts_local[0,0], pts_local[0,1])) |
| for i in range(1, len(pts_local)): qpath.lineTo(QPointF(pts_local[i,0], pts_local[i,1])) |
| qpath.closeSubpath() |
| outline_move = QGraphicsPathItem(qpath, parent=poly_item) |
| outline_move.setPen(QPen(L.color, 2)) |
| outline_move.setZValue(poly_item.zValue() + 1) |
|
|
| L.polygon_xy = pts_local |
| L.origin_local_xy = np.array([cx_local, cy_local], dtype=np.float32) |
| L.pixmap_item = poly_item |
| L.outline_item = outline_move |
| self._create_handles_for_layer(L) |
| self._ensure_preview_line(L) |
|
|
| |
| self.current_segment_hue_deg = 0.0 |
| self._expand_scene_to_item(poly_item, center=True) |
|
|
| else: |
| |
| pts = np.array([[p.x(), p.y()] for p in pts_scene], dtype=np.float32) |
| x0, y0 = pts.min(axis=0); x1, y1 = pts.max(axis=0) |
| cx, cy = (x0+x1)/2.0, (y0+y1)/2.0 |
|
|
| L.polygon_xy = pts |
| L.origin_local_xy = np.array([cx, cy], dtype=np.float32) |
|
|
| rgb = cv2.cvtColor(L.source_bgr, cv2.COLOR_BGR2RGB) |
| h, w = rgb.shape[:2] |
| mask = np.zeros((h, w), dtype=np.uint8) |
| cv2.fillPoly(mask, [pts.astype(np.int32)], 255) |
| rgba = np.dstack([rgb, mask]) |
| pm = np_rgba_to_qpixmap(rgba) |
|
|
| def on_change(): |
| if L.keyframes: |
| self._ensure_preview_line(L) |
| self._relayout_handles(L) |
| item = NotifyingPixmapItem(pm, on_change_cb=on_change) |
| item.setZValue(10 + len(self.layers)) |
| item.setFlag(QGraphicsPixmapItem.ItemIsMovable, True) |
| item.setFlag(QGraphicsPixmapItem.ItemIsSelectable, False) |
| item.setTransformationMode(Qt.FastTransformation) |
| item.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape) |
| item.setTransformOriginPoint(QPointF(cx, cy)) |
| self.scene.addItem(item); L.pixmap_item = item |
|
|
| qpath = QPainterPath(QPointF(pts[0,0], pts[0,1])) |
| for i in range(1, len(pts)): qpath.lineTo(QPointF(pts[i,0], pts[i,1])) |
| qpath.closeSubpath() |
| outline = QGraphicsPathItem(qpath, parent=item) |
| outline.setPen(QPen(L.color, 2)) |
| outline.setZValue(item.zValue() + 1) |
| L.outline_item = outline |
| self._create_handles_for_layer(L) |
|
|
| origin_scene = item.mapToScene(item.transformOriginPoint()) |
| L.keyframes.append(Keyframe(pos=np.array([origin_scene.x(), origin_scene.y()], dtype=np.float32), |
| rot_deg=float(item.rotation()), |
| scale=float(item.scale()) if item.scale()!=0 else 1.0, |
| hue_deg=0.0)) |
|
|
| if not (L.is_external): |
| self._refresh_inpaint_preview() |
|
|
| if self.temp_path_item is not None: self._remove_if_in_scene(self.temp_path_item); self.temp_path_item = None |
| if self.first_click_marker is not None: self._remove_if_in_scene(self.first_click_marker); self.first_click_marker = None |
| self.temp_points = [] |
| self.mode = Canvas.MODE_IDLE |
| |
| self._update_current_item_hue_preview() |
| return True |
|
|
| |
| def _create_handles_for_layer(self, L: Layer): |
| if L.polygon_xy is None or L.pixmap_item is None: |
| return |
| x0, y0 = L.polygon_xy.min(axis=0) |
| x1, y1 = L.polygon_xy.max(axis=0) |
| corners = [QPointF(x0,y0), QPointF(x1,y0), QPointF(x1,y1), QPointF(x0,y1)] |
| top_center = QPointF((x0+x1)/2.0, y0) |
| rot_pos = QPointF(top_center.x(), top_center.y() - 24) |
|
|
| box_path = QPainterPath(corners[0]) |
| for p in corners[1:]: |
| box_path.lineTo(p) |
| box_path.closeSubpath() |
| |
| bbox_item = QGraphicsPathItem(box_path, parent=L.pixmap_item) |
| pen = QPen(L.color, 1, Qt.DashLine) |
| pen.setCosmetic(True) |
| bbox_item.setPen(pen) |
| bbox_item.setZValue(L.pixmap_item.zValue() + 0.5) |
| L.handle_items.append(bbox_item) |
|
|
| for c in corners: |
| h = ScaleHandle(6, L.color, parent=L.pixmap_item) |
| h.setPos(c); h.set_item(L.pixmap_item) |
| L.handle_items.append(h) |
| rot_dot = RotateHandle(6, L.color, parent=L.pixmap_item) |
| rot_dot.setPos(rot_pos); rot_dot.set_item(L.pixmap_item) |
| L.handle_items.append(rot_dot) |
| tether = QGraphicsLineItem(QtCore.QLineF(top_center, rot_pos), L.pixmap_item) |
| pen_tether = QPen(L.color, 1) |
| pen_tether.setCosmetic(True) |
| tether.setPen(pen_tether) |
| tether.setZValue(L.pixmap_item.zValue() + 0.4) |
| L.handle_items.append(tether) |
|
|
| def _relayout_handles(self, L: Layer): |
| if L.polygon_xy is None or L.pixmap_item is None or not L.handle_items: |
| return |
| x0, y0 = L.polygon_xy.min(axis=0); x1, y1 = L.polygon_xy.max(axis=0) |
| corners = [QPointF(x0,y0), QPointF(x1,y0), QPointF(x1,y1), QPointF(x0,y1)] |
| top_center = QPointF((x0+x1)/2.0, y0) |
| rot_pos = QPointF(top_center.x(), top_center.y() - 24) |
| bbox_item = L.handle_items[0] |
| if isinstance(bbox_item, QGraphicsPathItem): |
| box_path = QPainterPath(corners[0]) |
| for p in corners[1:]: box_path.lineTo(p) |
| box_path.closeSubpath(); bbox_item.setPath(box_path) |
| for i in range(4): |
| h = L.handle_items[1+i] |
| if isinstance(h, QGraphicsEllipseItem): |
| h.setPos(corners[i]) |
| rot_dot = L.handle_items[5] |
| if isinstance(rot_dot, QGraphicsEllipseItem): |
| rot_dot.setPos(rot_pos) |
| tether = L.handle_items[6] |
| if isinstance(tether, QGraphicsLineItem): |
| tether.setLine(QtCore.QLineF(top_center, rot_pos)) |
|
|
| def _ensure_preview_line(self, L: Layer): |
| if L.pixmap_item is None or not L.keyframes: |
| return |
| origin_scene = L.pixmap_item.mapToScene(L.pixmap_item.transformOriginPoint()) |
| p0 = L.keyframes[-1].pos |
| p1 = np.array([origin_scene.x(), origin_scene.y()], dtype=np.float32) |
| if L.preview_line is None: |
| line = QGraphicsLineItem(p0[0], p0[1], p1[0], p1[1]) |
| line.setPen(QPen(L.color, 1, Qt.DashLine)) |
| line.setZValue(950) |
| self.scene.addItem(line) |
| L.preview_line = line |
| else: |
| L.preview_line.setLine(p0[0], p0[1], p1[0], p1[1]) |
|
|
| def _update_temp_path_item(self, color: QColor): |
| if self.temp_path_item is None: |
| self.temp_path_item = QGraphicsPathItem() |
| pen = QPen(color, 2) |
| self.temp_path_item.setPen(pen) |
| self.temp_path_item.setZValue(1000) |
| self.scene.addItem(self.temp_path_item) |
| if not self.temp_points: |
| self.temp_path_item.setPath(QPainterPath()) |
| return |
| path = QPainterPath(self.temp_points[0]) |
| for p in self.temp_points[1:]: |
| path.lineTo(p) |
| path.lineTo(self.temp_points[0]) |
| self.temp_path_item.setPath(path) |
|
|
| |
| def mousePressEvent(self, event): |
| |
| if self.mode != Canvas.MODE_DRAW_POLY and event.button() == Qt.RightButton: |
| self.end_segment_requested.emit() |
| event.accept() |
| return |
|
|
| if self.mode == Canvas.MODE_DRAW_POLY: |
| try: |
| p = event.position() |
| scene_pos = self.mapToScene(int(p.x()), int(p.y())) |
| except AttributeError: |
| scene_pos = self.mapToScene(event.pos()) |
|
|
| if event.button() == Qt.LeftButton: |
| self.temp_points.append(scene_pos) |
| if len(self.temp_points) == 1: |
| if self.first_click_marker is not None: |
| self.scene.removeItem(self.first_click_marker) |
| self.first_click_marker = QGraphicsEllipseItem(-3, -3, 6, 6) |
| self.first_click_marker.setBrush(QColor(0, 220, 0)) |
| self.first_click_marker.setPen(QPen(QColor(0, 0, 0), 1)) |
| self.first_click_marker.setZValue(1200) |
| self.scene.addItem(self.first_click_marker) |
| self.first_click_marker.setPos(scene_pos) |
| color = self.current_layer.color if self.current_layer else QColor(255, 0, 0) |
| self._update_temp_path_item(color) |
| elif event.button() == Qt.RightButton: |
| |
| preserve = ( |
| self.current_layer is not None |
| and self.current_layer.pixmap_item is not None |
| and self.current_layer.is_external |
| ) |
| ok = self.finish_polygon(preserve_motion=preserve) |
| self.polygon_finished.emit(ok) |
| if not ok: |
| QMessageBox.information(self, "Polygon", "Need at least 3 points.") |
| event.accept() |
| return |
| |
| return |
|
|
| super().mousePressEvent(event) |
|
|
|
|
| def mouseDoubleClickEvent(self, event): |
| if self.mode == Canvas.MODE_DRAW_POLY: |
| return |
| super().mouseDoubleClickEvent(event) |
|
|
| def keyPressEvent(self, event: QtGui.QKeyEvent): |
| if self.mode == Canvas.MODE_DRAW_POLY: |
| if event.key() in (Qt.Key_Return, Qt.Key_Enter): |
| |
| return |
| elif event.key() == Qt.Key_Backspace: |
| if self.temp_points: |
| self.temp_points.pop() |
| color = self.current_layer.color if self.current_layer else QColor(255,0,0) |
| self._update_temp_path_item(color) |
| return |
| elif event.key() == Qt.Key_Escape: |
| if self.temp_path_item is not None: self.scene.removeItem(self.temp_path_item); self.temp_path_item = None |
| if self.first_click_marker is not None: self.scene.removeItem(self.first_click_marker); self.first_click_marker = None |
| self.temp_points = [] |
| self.mode = Canvas.MODE_IDLE |
| return |
| super().keyPressEvent(event) |
|
|
| |
| def end_segment_add_keyframe(self): |
| if not (self.current_layer and self.current_layer.pixmap_item and (self.current_layer.polygon_xy is not None) and self.current_layer.keyframes): |
| return False |
| item = self.current_layer.pixmap_item |
| origin_scene = item.mapToScene(item.transformOriginPoint()) |
| kf = Keyframe( |
| pos=np.array([origin_scene.x(), origin_scene.y()], dtype=np.float32), |
| rot_deg=float(item.rotation()), |
| scale=float(item.scale()) if item.scale()!=0 else 1.0, |
| hue_deg=float(self.current_segment_hue_deg) |
| ) |
| L = self.current_layer |
| if len(L.keyframes) >= 1: |
| p0 = L.keyframes[-1].pos; p1 = kf.pos |
| if L.preview_line is not None: |
| self.scene.removeItem(L.preview_line); L.preview_line = None |
| line = QGraphicsLineItem(p0[0], p0[1], p1[0], p1[1]) |
| line.setPen(QPen(L.color, 2)); line.setZValue(900); self.scene.addItem(line) |
| L.path_lines.append(line) |
| L.keyframes.append(kf) |
| self._ensure_preview_line(L) |
| |
| self.current_segment_hue_deg = 0.0 |
| |
| self._update_current_item_hue_preview() |
| return True |
|
|
| def has_pending_transform(self) -> bool: |
| L = self.current_layer |
| if not (L and L.pixmap_item and L.keyframes): return False |
| last = L.keyframes[-1] |
| item = L.pixmap_item |
| origin_scene = item.mapToScene(item.transformOriginPoint()) |
| pos = np.array([origin_scene.x(), origin_scene.y()], dtype=np.float32) |
| dpos = np.linalg.norm(pos - last.pos) |
| drot = abs(float(item.rotation()) - last.rot_deg) |
| dscale = abs((float(item.scale()) if item.scale()!=0 else 1.0) - last.scale) |
| |
| return (dpos > 0.5) or (drot > 0.1) or (dscale > 1e-3) |
|
|
| def revert_to_last_keyframe(self, L: Optional[Layer] = None): |
| if L is None: L = self.current_layer |
| if not (L and L.pixmap_item and L.keyframes): return |
| last = L.keyframes[-1] |
| item = L.pixmap_item |
| item.setRotation(last.rot_deg); item.setScale(last.scale) |
| origin_scene = item.mapToScene(item.transformOriginPoint()) |
| d = QPointF(last.pos[0]-origin_scene.x(), last.pos[1]-origin_scene.y()) |
| item.setPos(item.pos() + d) |
| self._ensure_preview_line(L) |
| |
| self.current_segment_hue_deg = last.hue_deg |
| self._update_current_item_hue_preview() |
|
|
| def _sample_keyframes_constant_speed_with_seg(self, keyframes: List[Keyframe], T: int): |
| """ |
| Allocate frames to segments proportional to their Euclidean length so that |
| translation happens at constant speed across the whole path. |
| Returns (pos[T,2], scl[T], rot[T], seg_idx[T], t[T]). |
| """ |
| K = len(keyframes) |
| assert K >= 1 |
| import math |
| if T <= 0: |
| |
| p0 = keyframes[0].pos.astype(np.float32) |
| return (np.repeat(p0[None, :], 0, axis=0), |
| np.zeros((0,), np.float32), |
| np.zeros((0,), np.float32), |
| np.zeros((0,), np.int32), |
| np.zeros((0,), np.float32)) |
|
|
| if K == 1: |
| p0 = keyframes[0].pos.astype(np.float32) |
| pos = np.repeat(p0[None, :], T, axis=0) |
| scl = np.full((T,), float(keyframes[0].scale), dtype=np.float32) |
| rot = np.full((T,), float(keyframes[0].rot_deg), dtype=np.float32) |
| seg_idx = np.zeros((T,), dtype=np.int32) |
| t = np.zeros((T,), dtype=np.float32) |
| return pos, scl, rot, seg_idx, t |
|
|
| |
| P = np.array([kf.pos for kf in keyframes], dtype=np.float32) |
| seg_vec = P[1:] - P[:-1] |
| lengths = np.linalg.norm(seg_vec, axis=1) |
| total_len = float(lengths.sum()) |
|
|
| def _per_seg_counts_uniform(): |
| |
| base = np.zeros((K-1,), dtype=np.int32) |
| if T > 0: |
| |
| q, r = divmod(T, K-1) |
| base[:] = q |
| base[:r] += 1 |
| return base |
|
|
| if total_len <= 1e-6: |
| counts = _per_seg_counts_uniform() |
| else: |
| |
| raw = (lengths / total_len) * T |
| base = np.floor(raw).astype(np.int32) |
| remainder = T - int(base.sum()) |
| if remainder > 0: |
| order = np.argsort(-(raw - base)) |
| base[order[:remainder]] += 1 |
| counts = base |
|
|
| |
| pos_list, scl_list, rot_list, seg_idx_list, t_list = [], [], [], [], [] |
|
|
| for s in range(K - 1): |
| n = int(counts[s]) |
| if n <= 0: |
| continue |
| |
| ts = np.linspace(0.0, 1.0, n, endpoint=False, dtype=np.float32) |
|
|
| p0, p1 = P[s], P[s + 1] |
| s0, s1 = max(1e-6, float(keyframes[s].scale)), max(1e-6, float(keyframes[s + 1].scale)) |
| r0, r1 = float(keyframes[s].rot_deg), float(keyframes[s + 1].rot_deg) |
|
|
| |
| pos_seg = (1 - ts)[:, None] * p0[None, :] + ts[:, None] * p1[None, :] |
| scl_seg = np.exp((1 - ts) * math.log(s0) + ts * math.log(s1)) |
| rot_seg = (1 - ts) * r0 + ts * r1 |
|
|
| pos_list.append(pos_seg.astype(np.float32)) |
| scl_list.append(scl_seg.astype(np.float32)) |
| rot_list.append(rot_seg.astype(np.float32)) |
| seg_idx_list.append(np.full((n,), s, dtype=np.int32)) |
| t_list.append(ts.astype(np.float32)) |
|
|
| |
| N = sum(int(c) for c in counts) |
| if N < T: |
| |
| p_end = P[-1].astype(np.float32) |
| extra = T - N |
| pos_list.append(np.repeat(p_end[None, :], extra, axis=0)) |
| scl_list.append(np.full((extra,), float(keyframes[-1].scale), dtype=np.float32)) |
| rot_list.append(np.full((extra,), float(keyframes[-1].rot_deg), dtype=np.float32)) |
| |
| seg_idx_list.append(np.full((extra,), max(0, K - 2), dtype=np.int32)) |
| t_list.append(np.zeros((extra,), dtype=np.float32)) |
|
|
| pos = np.vstack(pos_list) if pos_list else np.zeros((T, 2), dtype=np.float32) |
| scl = np.concatenate(scl_list) if scl_list else np.zeros((T,), dtype=np.float32) |
| rot = np.concatenate(rot_list) if rot_list else np.zeros((T,), dtype=np.float32) |
| seg_idx = np.concatenate(seg_idx_list) if seg_idx_list else np.zeros((T,), dtype=np.int32) |
| t = np.concatenate(t_list) if t_list else np.zeros((T,), dtype=np.float32) |
|
|
| |
| if len(pos) > T: |
| pos, scl, rot, seg_idx, t = pos[:T], scl[:T], rot[:T], seg_idx[:T], t[:T] |
| elif len(pos) < T: |
| pad = T - len(pos) |
| pos = np.vstack([pos, np.repeat(pos[-1:,:], pad, axis=0)]) |
| scl = np.concatenate([scl, np.repeat(scl[-1:], pad)]) |
| rot = np.concatenate([rot, np.repeat(rot[-1:], pad)]) |
| seg_idx = np.concatenate([seg_idx, np.repeat(seg_idx[-1:], pad)]) |
| t = np.concatenate([t, np.repeat(t[-1:], pad)]) |
|
|
| return pos.astype(np.float32), scl.astype(np.float32), rot.astype(np.float32), seg_idx.astype(np.int32), t.astype(np.float32) |
|
|
|
|
| def undo(self) -> bool: |
| if self.mode == Canvas.MODE_DRAW_POLY and self.temp_points: |
| self.temp_points.pop() |
| color = self.current_layer.color if self.current_layer else QColor(255,0,0) |
| self._update_temp_path_item(color) |
| return True |
| if self.has_pending_transform(): |
| self.revert_to_last_keyframe() |
| return True |
| if self.current_layer and len(self.current_layer.keyframes) > 1: |
| L = self.current_layer |
| if L.path_lines: |
| line = L.path_lines.pop(); self.scene.removeItem(line) |
| L.keyframes.pop() |
| self.revert_to_last_keyframe(L) |
| return True |
| if self.current_layer: |
| L = self.current_layer |
| if (L.pixmap_item is not None) and (len(L.keyframes) <= 1) and (len(L.path_lines) == 0): |
| if L.preview_line is not None: |
| self.scene.removeItem(L.preview_line); L.preview_line = None |
| if L.outline_item is not None: |
| self.scene.removeItem(L.outline_item); L.outline_item = None |
| for it in L.handle_items: |
| self.scene.removeItem(it) |
| L.handle_items.clear() |
| self.scene.removeItem(L.pixmap_item); L.pixmap_item = None |
| try: |
| idx = self.layers.index(L) |
| self.layers.pop(idx) |
| except ValueError: |
| pass |
| self.current_layer = self.layers[-1] if self.layers else None |
| if (L.is_external is False): |
| self._refresh_inpaint_preview() |
| return True |
| return False |
|
|
| |
| def build_preview_frames(self, T_total: int) -> Optional[List[np.ndarray]]: |
| if self.base_bgr is None: |
| return None |
| H, W = self.base_bgr.shape[:2] |
| total_mask = np.zeros((H, W), dtype=bool) |
| for L in self.layers: |
| if not L.has_polygon() or L.is_external: |
| continue |
| poly0 = L.polygon_xy.astype(np.int32) |
| mask = np.zeros((H, W), dtype=np.uint8); cv2.fillPoly(mask, [poly0], 255) |
| total_mask |= (mask > 0) |
| background = inpaint_background(self.base_bgr, total_mask) |
|
|
| all_layer_frames = [] |
| has_any = False |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| for L in self.layers: |
| if not L.has_polygon() or len(L.keyframes) < 2: |
| continue |
| has_any = True |
|
|
| |
| path_xy, scales, rots, seg_idx, t = self._sample_keyframes_constant_speed_with_seg(L.keyframes, T_total) |
|
|
| origin_xy = L.origin_local_xy if L.origin_local_xy is not None else L.polygon_xy.mean(axis=0) |
|
|
| |
| K = len(L.keyframes) |
| hue_values = [L.keyframes[k].hue_deg for k in range(K)] |
| hue_to_frames: Dict[int, List[np.ndarray]] = {} |
| for k in range(K): |
| bgr_h = apply_hue_shift_bgr(L.source_bgr, hue_values[k]) |
| frames_h, _ = animate_polygon(bgr_h, L.polygon_xy, path_xy, scales, rots, |
| interp=cv2.INTER_LINEAR, origin_xy=origin_xy) |
| hue_to_frames[k] = frames_h |
|
|
| |
| frames_rgba = [] |
| for i in range(T_total): |
| s = int(seg_idx[i]) |
| w = float(t[i]) |
| A = hue_to_frames[s][i].astype(np.float32) |
| B = hue_to_frames[s+1][i].astype(np.float32) |
| mix = (1.0 - w) * A + w * B |
| frames_rgba.append(np.clip(mix, 0, 255).astype(np.uint8)) |
| all_layer_frames.append(frames_rgba) |
|
|
| if not has_any: |
| return None |
| frames_out = composite_frames(background, all_layer_frames) |
| return frames_out |
|
|
| def play_demo(self, fps: int, T_total: int): |
| frames = self.build_preview_frames(T_total) |
| if not frames: |
| QMessageBox.information(self, "Play Demo", "Nothing to play yet. Add a polygon and keyframes.") |
| return |
| self.play_frames = frames |
| self.play_index = 0 |
| if self.player_item is None: |
| self.player_item = QGraphicsPixmapItem() |
| self.player_item.setZValue(5000) |
| self.scene.addItem(self.player_item) |
| self.player_item.setVisible(True) |
| self._on_play_tick() |
| interval_ms = max(1, int(1000 / max(1, fps))) |
| self.play_timer.start(interval_ms) |
|
|
| def _on_play_tick(self): |
| if not self.play_frames or self.play_index >= len(self.play_frames): |
| self.play_timer.stop() |
| if self.player_item is not None: |
| self.player_item.setVisible(False) |
| return |
| frame = self.play_frames[self.play_index] |
| self.play_index += 1 |
| self.player_item.setPixmap(np_bgr_to_qpixmap(frame)) |
|
|
| |
| |
| |
|
|
| class MainWindow(QMainWindow): |
| def __init__(self): |
| super().__init__() |
| self.setWindowTitle("Time-to-Move: Cut & Drag") |
| self.resize(1180, 840) |
|
|
| self.canvas = Canvas(self) |
| self.canvas.polygon_finished.connect(self._on_canvas_polygon_finished) |
| self.canvas.end_segment_requested.connect(self.on_end_segment) |
|
|
| |
| self.instruction_label = QLabel() |
| self.instruction_label.setWordWrap(True) |
| self.instruction_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) |
| self.instruction_label.setStyleSheet(""" |
| QLabel { |
| background: #f7f7fa; |
| border-bottom: 1px solid #ddd; |
| padding: 10px 12px; |
| font-size: 15px; |
| color: #222; |
| } |
| """) |
| self._set_instruction("Welcome! • Select Image to begin.") |
|
|
| central = QWidget() |
| v = QVBoxLayout(central) |
| v.setContentsMargins(0,0,0,0); v.setSpacing(0) |
| v.addWidget(self.instruction_label) |
| v.addWidget(self.canvas) |
| self.setCentralWidget(central) |
|
|
| |
| self.placing_external: bool = False |
| self.placing_layer: Optional[Layer] = None |
|
|
| |
| tb = QToolBar("Tools") |
| self.addToolBar(Qt.LeftToolBarArea, tb) |
| tb.setOrientation(Qt.Vertical) |
|
|
| def add_btn(text: str, slot, icon: Optional[QIcon] = None): |
| btn = QPushButton(text) |
| if icon: btn.setIcon(icon) |
| btn.setCursor(Qt.PointingHandCursor) |
| btn.setMinimumWidth(180) |
| btn.clicked.connect(slot) |
| tb.addWidget(btn); return btn |
|
|
| |
| self.cmb_fit = QComboBox(); self.cmb_fit.addItems(["Center Crop", "Center Pad"]) |
| tb.addWidget(self.cmb_fit) |
| self.canvas.fit_mode_combo = self.cmb_fit |
|
|
| |
| line_sep = QFrame(); line_sep.setFrameShape(QFrame.HLine); line_sep.setFrameShadow(QFrame.Plain) |
| line_sep.setStyleSheet("color: #888; border-top: 1px dotted #888; margin: 8px 0;") |
| tb.addWidget(line_sep) |
|
|
| |
| self.btn_select = add_btn("🖼️ Select Image", self.on_select_base) |
|
|
| |
| self.pent_icon = self.canvas.make_pentagon_icon() |
| self.btn_add_poly = add_btn("Add Polygon", self.on_add_polygon_toggled, icon=self.pent_icon) |
| self.add_poly_active = False |
|
|
| |
| self.btn_add_external = add_btn("🖼️➕ Add External Image", self.on_add_or_place_external) |
|
|
| |
| tb.addSeparator() |
| tb.addWidget(QLabel("Hue Transform")) |
| hue_row = QWidget(); row = QHBoxLayout(hue_row); row.setContentsMargins(0,0,0,0) |
| self.sld_hue = QSlider(Qt.Horizontal); self.sld_hue.setRange(-180, 180); self.sld_hue.setValue(0) |
| btn_default = QPushButton("Default"); btn_default.setCursor(Qt.PointingHandCursor); btn_default.setFixedWidth(70) |
| row.addWidget(self.sld_hue, 1); row.addWidget(btn_default, 0) |
| tb.addWidget(hue_row) |
| self.sld_hue.valueChanged.connect(self.on_hue_changed) |
| btn_default.clicked.connect(lambda: self.sld_hue.setValue(0)) |
|
|
| |
| self.btn_end_seg = add_btn("🎯 End Segment", self.on_end_segment) |
| self.btn_undo = add_btn("↩️ Undo", self.on_undo) |
|
|
| tb.addSeparator() |
| tb.addWidget(QLabel("Total Frames:")) |
| self.spn_total_frames = QSpinBox(); self.spn_total_frames.setRange(1, 2000); self.spn_total_frames.setValue(81) |
| tb.addWidget(self.spn_total_frames) |
| tb.addWidget(QLabel("FPS:")) |
| self.spn_fps = QSpinBox(); self.spn_fps.setRange(1, 120); self.spn_fps.setValue(16) |
| tb.addWidget(self.spn_fps) |
|
|
| tb.addSeparator() |
| self.btn_play = add_btn("▶️ Play Demo", self.on_play_demo) |
| tb.addWidget(QLabel("Prompt")) |
| self.txt_prompt = QPlainTextEdit() |
| self.txt_prompt.setFixedHeight(80) |
| self.txt_prompt.setMinimumWidth(180) |
| |
| |
| tb.addWidget(self.txt_prompt) |
| self.btn_save = add_btn("💾 Save", self.on_save) |
| self.btn_new = add_btn("🆕 New", self.on_new) |
| self.btn_exit = add_btn("⏹️ Exit", self.close) |
|
|
| |
| status = QToolBar("Status") |
| self.addToolBar(Qt.BottomToolBarArea, status) |
| self.status_label = QLabel("Ready") |
| status.addWidget(self.status_label) |
|
|
| |
| def _set_instruction(self, text: str): |
| self.instruction_label.setText(text) |
|
|
| |
| def _block_if_pending_segment(self, action_label: str) -> bool: |
| if self.canvas.current_layer and self.canvas.has_pending_transform(): |
| QMessageBox.information( |
| self, "Finish Segment", |
| f"Please end the current segment (click '🎯 End Segment') before {action_label}." |
| ) |
| self._set_instruction("Finish current segment: drag/scale/rotate as needed, adjust Hue, then click 🎯 End Segment.") |
| return True |
| return False |
|
|
| |
| def on_select_base(self): |
| if self._block_if_pending_segment("changing the base image"): |
| return |
| path, _ = QFileDialog.getOpenFileName(self, "Select image", "", "Images/Videos (*.png *.jpg *.jpeg *.bmp *.mp4 *.mov *.avi *.mkv)") |
| if not path: |
| self._set_instruction("No image selected. Click ‘Select Image’ to begin.") |
| return |
| try: |
| raw = load_first_frame(path) |
| except Exception as e: |
| QMessageBox.critical(self, "Load", f"Failed to load: {e}") |
| return |
| self.canvas.set_base_image(raw) |
| self.add_poly_active = False |
| self.btn_add_poly.setText("Add Polygon") |
| self.placing_external = False; self.placing_layer = None |
| self.btn_add_external.setText("🖼️➕ Add External Image") |
| self.status_label.setText("Base loaded.") |
| self._set_instruction("Step 1: Add a polygon (Add Polygon), or add an external sprite (Add External Image).") |
|
|
| def on_add_polygon_toggled(self): |
| if self.placing_external: |
| QMessageBox.information(self, "Place External First", |
| "Please place the external image first (click ‘✅ Place External Image’).") |
| self._set_instruction("Place External: drag/scale/rotate to choose starting pose, then click ‘✅ Place External Image’.") |
| return |
|
|
| if (not self.add_poly_active) and self._block_if_pending_segment("adding a polygon"): |
| return |
|
|
| if not self.add_poly_active: |
| if self.canvas.base_bgr is None: |
| QMessageBox.information(self, "Add Polygon", "Please select an image first.") |
| self._set_instruction("Click ‘Select Image’ to begin.") |
| return |
|
|
| |
| |
| |
| if (self.canvas.current_layer is None) or (not self.canvas.current_layer.is_external): |
| |
| self.canvas.new_layer_from_source( |
| name=f"Layer {len(self.canvas.layers)+1}", |
| source_bgr=self.canvas.base_bgr, |
| is_external=False |
| ) |
| else: |
| |
| self.canvas.start_draw_polygon(preserve_motion=True) |
| |
|
|
| self.add_poly_active = True |
| self.btn_add_poly.setText("✅ Finish Polygon Selection") |
| self.status_label.setText("Drawing polygon…") |
| self._set_instruction("Polygon mode: Left-click to add points. Backspace = undo point. Right-click = finish. Esc = cancel.") |
| else: |
| |
| preserve = (self.canvas.current_layer is not None and |
| self.canvas.current_layer.pixmap_item is not None and |
| self.canvas.current_layer.is_external) |
| ok = self.canvas.finish_polygon(preserve_motion=preserve) |
| if not ok: |
| QMessageBox.information(self, "Polygon", "Need at least 3 points (keep adding).") |
| self._set_instruction("Keep adding polygon points (≥3). Right-click to finish.") |
| return |
| self.add_poly_active = False |
| self.btn_add_poly.setText("Add Polygon") |
| self.status_label.setText("Polygon ready.") |
| self._set_instruction( |
| "Drag to move, use corner circles to scale, top dot to rotate. " |
| "Adjust Hue if you like, then click ‘🎯 End Segment’ or Right Click to record a move." |
| ) |
| def on_add_or_place_external(self): |
| |
| if self.placing_external and self.placing_layer is not None: |
| try: |
| |
| self.canvas.place_external_initial_keyframe(self.placing_layer) |
| |
| self.canvas.current_layer = self.placing_layer |
| |
| self.canvas._ensure_preview_line(self.placing_layer) |
| finally: |
| self.placing_external = False |
| self.placing_layer = None |
| self.btn_add_external.setText("🖼️➕ Add External Image") |
| self.status_label.setText("External starting pose locked.") |
| self._set_instruction("Now drag/scale/rotate and click ‘🎯 End Segment’ to record movement.") |
| return |
|
|
| |
| if self._block_if_pending_segment("adding an external image"): |
| return |
| if self.canvas.base_bgr is None: |
| QMessageBox.information(self, "External", "Select a base image first.") |
| self._set_instruction("Click ‘Select Image’ to begin.") |
| return |
|
|
| path, _ = QFileDialog.getOpenFileName( |
| self, "Select external image", "", |
| "Images/Videos (*.png *.jpg *.jpeg *.bmp *.mp4 *.mov *.avi *.mkv)" |
| ) |
| if not path: |
| self._set_instruction("External not chosen. You can Add External Image later.") |
| return |
|
|
| try: |
| raw = load_first_frame(path) |
| except Exception as e: |
| QMessageBox.critical(self, "Load", f"Failed to load external: {e}") |
| return |
|
|
| L = self.canvas.add_external_sprite_layer(raw) |
| if L is None: |
| QMessageBox.critical(self, "External", "Failed to create external layer.") |
| return |
|
|
| self.placing_external = True |
| self.placing_layer = L |
| self.canvas.current_layer = L |
| self.btn_add_external.setText("✅ Place External Image") |
| self.status_label.setText("Place external image.") |
| self._set_instruction("Place External: drag into view, scale with corner circles, rotate with top dot. Then click ‘✅ Place External Image’.") |
|
|
|
|
| def _on_canvas_polygon_finished(self, ok: bool): |
| if ok: |
| self.add_poly_active = False |
| self.btn_add_poly.setText("Add Polygon") |
| self.status_label.setText("Polygon ready.") |
| self._set_instruction( |
| "Drag to move, use corner circles to scale, top dot to rotate. " |
| "Adjust Hue if you like, then click ‘🎯 End Segment’ or Right Click to record a move." |
| ) |
| else: |
| |
| pass |
|
|
| def on_hue_changed(self, val: int): |
| self.canvas.current_segment_hue_deg = float(val) |
| self.canvas._update_current_item_hue_preview() |
|
|
| def on_end_segment(self): |
| if self.placing_external: |
| QMessageBox.information(self, "Place External First", |
| "Please place the external image first (click ‘✅ Place External Image’).") |
| self._set_instruction("Place External: drag/scale/rotate to choose starting pose, then click ‘✅ Place External Image’.") |
| return |
| ok = self.canvas.end_segment_add_keyframe() |
| if ok: |
| n = len(self.canvas.current_layer.keyframes) if self.canvas.current_layer else 0 |
| self.status_label.setText(f"Keyframe #{n} added.") |
| self._set_instruction( |
| "Segment added! Move again for the next leg, adjust Hue if you like, " |
| "then click ‘🎯 End Segment’ or Right Click to record a move." |
| ) |
| else: |
| QMessageBox.information(self, "End Segment", "Nothing to record yet. Add/finish a polygon or add/place an external sprite first.") |
| self._set_instruction("Add a polygon (base/external) or place an external image, then drag and click ‘🎯 End Segment’.") |
|
|
| def on_undo(self): |
| if self.placing_external and self.placing_layer is not None: |
| L = self.placing_layer |
| if L.pixmap_item is not None: self.canvas.scene.removeItem(L.pixmap_item) |
| if L.outline_item is not None: self.canvas.scene.removeItem(L.outline_item) |
| for it in L.handle_items: self.canvas.scene.removeItem(it) |
| try: |
| idx = self.canvas.layers.index(L) |
| self.canvas.layers.pop(idx) |
| except ValueError: |
| pass |
| self.canvas.current_layer = self.canvas.layers[-1] if self.canvas.layers else None |
| self.placing_layer = None |
| self.placing_external = False |
| self.btn_add_external.setText("🖼️➕ Add External Image") |
| self.status_label.setText("External placement canceled.") |
| self._set_instruction("External placement canceled. Add External Image again or continue editing.") |
| return |
|
|
| if self.canvas.undo(): |
| self.status_label.setText("Undo applied.") |
| self._set_instruction("Undone. Continue editing, or click ‘🎯 End Segment’ to record movement.") |
| else: |
| self.status_label.setText("Nothing to undo.") |
| self._set_instruction("Nothing to undo. Drag/scale/rotate and click ‘🎯 End Segment’, or add new shapes.") |
|
|
| def _sample_keyframes_uniform(self, keyframes: List[Keyframe], T: int): |
| K = len(keyframes); assert K >= 2 |
| segs = K - 1 |
| u = np.linspace(0.0, float(segs), T, dtype=np.float32) |
| seg_idx = np.minimum(np.floor(u).astype(int), segs - 1) |
| t = u - seg_idx |
| k0 = np.array([[keyframes[i].pos[0], keyframes[i].pos[1], keyframes[i].scale, keyframes[i].rot_deg] for i in seg_idx], dtype=np.float32) |
| k1 = np.array([[keyframes[i+1].pos[0], keyframes[i+1].pos[1], keyframes[i+1].scale, keyframes[i+1].rot_deg] for i in seg_idx], dtype=np.float32) |
| pos0 = k0[:, :2]; pos1 = k1[:, :2] |
| s0 = np.maximum(1e-6, k0[:, 2]); s1 = np.maximum(1e-6, k1[:, 2]) |
| r0 = k0[:, 3]; r1 = k1[:, 3] |
| pos = (1 - t)[:, None] * pos0 + t[:, None] * pos1 |
| scl = np.exp((1 - t) * np.log(s0) + t * np.log(s1)) |
| rot = (1 - t) * r0 + t * r1 |
| return pos.astype(np.float32), scl.astype(np.float32), rot.astype(np.float32) |
|
|
| def on_play_demo(self): |
| if self.canvas.base_bgr is None: |
| QMessageBox.information(self, "Play Demo", "Select an image first.") |
| self._set_instruction("Click ‘Select Image’ to begin.") |
| return |
| has_segments = any((L.polygon_xy is not None and len(L.keyframes) >= 2) for L in self.canvas.layers) |
| if not has_segments: |
| QMessageBox.information(self, "Play Demo", "No motion segments yet. Drag something and click ‘🎯 End Segment’ at least once.") |
| self._set_instruction("Create at least one movement: drag/scale/rotate then click ‘🎯 End Segment’.") |
| return |
| fps = int(self.spn_fps.value()) |
| T_total = int(self.spn_total_frames.value()) |
| self.canvas.play_demo(fps=fps, T_total=T_total) |
| self._set_instruction("Playing demo… When it ends, you’ll return to the editor. Tweak and play again, or 💾 Save.") |
|
|
| def on_new(self): |
| if self._block_if_pending_segment("starting a new project"): |
| return |
| self.canvas.scene.clear() |
| self.canvas.layers.clear() |
| self.canvas.current_layer = None |
| self.canvas.base_bgr = None |
| self.canvas.base_preview_bgr = None |
| self.canvas.base_item = None |
| self.add_poly_active = False |
| self.btn_add_poly.setText("Add Polygon") |
| self.placing_external = False; self.placing_layer = None |
| self.btn_add_external.setText("🖼️➕ Add External Image") |
| if hasattr(self, "txt_prompt"): |
| self.txt_prompt.clear() |
| self.status_label.setText("Ready") |
| self._set_instruction("New project. Click ‘Select Image’ to begin.") |
| self.on_select_base() |
|
|
| def on_save(self): |
| if self._block_if_pending_segment("saving"): |
| return |
| if self.canvas.base_bgr is None or not self.canvas.layers: |
| QMessageBox.information(self, "Save", "Load an image and add at least one polygon/sprite first.") |
| self._set_instruction("Add a polygon (base/external), record segments (🎯 End Segment), then Save.") |
| return |
|
|
| |
| for L in self.canvas.layers: |
| if L.pixmap_item and L.polygon_xy is not None and len(L.keyframes) == 1: |
| self.canvas.current_layer = L |
| self.canvas.end_segment_add_keyframe() |
|
|
| |
| base_dir = QtWidgets.QFileDialog.getExistingDirectory( |
| self, "Select output directory", "" |
| ) |
| if not base_dir: |
| self._set_instruction("Save canceled. You can keep editing or try ▶️ Play Demo.") |
| return |
|
|
| |
| subdir_name, ok = QtWidgets.QInputDialog.getText( |
| self, "Subfolder Name", "Create a new subfolder in the selected directory:" |
| ) |
| if not ok or not subdir_name.strip(): |
| self._set_instruction("Save canceled (no subfolder name).") |
| return |
| subdir_name = subdir_name.strip() |
|
|
| final_dir = os.path.join(base_dir, subdir_name) |
| if os.path.exists(final_dir): |
| resp = QMessageBox.question( |
| self, "Folder exists", |
| f"'{subdir_name}' already exists in the selected directory.\n" |
| f"Use it and overwrite files?", |
| QMessageBox.Yes | QMessageBox.No, QMessageBox.No |
| ) |
| if resp != QMessageBox.Yes: |
| self._set_instruction("Save canceled. Choose another name or directory next time.") |
| return |
| else: |
| try: |
| os.makedirs(final_dir, exist_ok=True) |
| except Exception as e: |
| QMessageBox.critical(self, "Save", f"Failed to create folder:\n{e}") |
| return |
|
|
| try: |
| prompt_text = self.txt_prompt.toPlainText() |
| except Exception: |
| prompt_text = "" |
| try: |
| with open(os.path.join(final_dir, "prompt.txt"), "w", encoding="utf-8") as f: |
| f.write(prompt_text) |
| except Exception as e: |
| |
| print(f"[warn] Failed to write prompt.txt: {e}") |
|
|
| |
| first_frame_path = os.path.join(final_dir, "first_frame.png") |
| motion_path = os.path.join(final_dir, "motion_signal.mp4") |
| mask_path = os.path.join(final_dir, "mask.mp4") |
| base_title = subdir_name |
| npy_path = os.path.join(final_dir, f"{base_title}_polygons.npy") |
|
|
| fps = int(self.spn_fps.value()) |
| T_total = int(self.spn_total_frames.value()) |
|
|
| |
| H, W = self.canvas.base_bgr.shape[:2] |
| total_mask = np.zeros((H, W), dtype=bool) |
| for L in self.canvas.layers: |
| if L.polygon_xy is None: |
| continue |
| if L.is_external: |
| continue |
| poly0 = L.polygon_xy.astype(np.int32) |
| m = np.zeros((H, W), dtype=np.uint8) |
| cv2.fillPoly(m, [poly0], 255) |
| total_mask |= (m > 0) |
| background = inpaint_background(self.canvas.base_bgr, total_mask) |
|
|
| |
| all_layer_frames = [] |
| layer_polys = [] |
| for L in self.canvas.layers: |
| if L.polygon_xy is None or len(L.keyframes) < 2: |
| continue |
|
|
| def sample_keyframes_uniform_with_seg(keyframes: List[Keyframe], T: int): |
| K = len(keyframes); assert K >= 1 |
| if K == 1: |
| pos = np.repeat(keyframes[0].pos[None, :], T, axis=0).astype(np.float32) |
| scl = np.full((T,), keyframes[0].scale, dtype=np.float32) |
| rot = np.full((T,), keyframes[0].rot_deg, dtype=np.float32) |
| seg_idx = np.zeros((T,), dtype=np.int32) |
| t = np.zeros((T,), dtype=np.float32) |
| return pos, scl, rot, seg_idx, t |
| segs = K - 1 |
| u = np.linspace(0.0, float(segs), T, dtype=np.float32) |
| seg_idx = np.minimum(np.floor(u).astype(int), segs - 1) |
| t = u - seg_idx |
| k0 = np.array([[keyframes[i].pos[0], keyframes[i].pos[1], keyframes[i].scale, keyframes[i].rot_deg] for i in seg_idx], dtype=np.float32) |
| k1 = np.array([[keyframes[i+1].pos[0], keyframes[i+1].pos[1], keyframes[i+1].scale, keyframes[i+1].rot_deg] for i in seg_idx], dtype=np.float32) |
| pos0 = k0[:, :2]; pos1 = k1[:, :2] |
| s0 = np.maximum(1e-6, k0[:, 2]); s1 = np.maximum(1e-6, k1[:, 2]) |
| r0 = k0[:, 3]; r1 = k1[:, 3] |
| pos = (1 - t)[:, None] * pos0 + t[:, None] * pos1 |
| scl = np.exp((1 - t) * np.log(s0) + t * np.log(s1)) |
| rot = (1 - t) * r0 + t * r1 |
| return pos.astype(np.float32), scl.astype(np.float32), rot.astype(np.float32), seg_idx, t |
|
|
| path_xy, scales, rots, seg_idx, t = sample_keyframes_uniform_with_seg(L.keyframes, T_total) |
| origin_xy = L.origin_local_xy if L.origin_local_xy is not None else L.polygon_xy.mean(axis=0) |
|
|
| |
| K = len(L.keyframes) |
| hue_values = [L.keyframes[k].hue_deg for k in range(K)] |
| hue_to_frames: Dict[int, List[np.ndarray]] = {} |
| polys_for_layer = None |
| for k in range(K): |
| bgr_h = apply_hue_shift_bgr(L.source_bgr, hue_values[k]) |
| frames_h, polys = animate_polygon( |
| bgr_h, L.polygon_xy, path_xy, scales, rots, |
| interp=cv2.INTER_LINEAR, origin_xy=origin_xy |
| ) |
| hue_to_frames[k] = frames_h |
| if polys_for_layer is None: |
| polys_for_layer = np.array(polys, dtype=np.float32) |
| if polys_for_layer is not None: |
| layer_polys.append(polys_for_layer) |
|
|
| |
| frames_rgba = [] |
| for i in range(T_total): |
| s = int(seg_idx[i]) |
| w = float(t[i]) |
| A = hue_to_frames[s][i].astype(np.float32) |
| B = hue_to_frames[s+1][i].astype(np.float32) |
| mix = (1.0 - w) * A + w * B |
| frames_rgba.append(np.clip(mix, 0, 255).astype(np.uint8)) |
| all_layer_frames.append(frames_rgba) |
|
|
| if not all_layer_frames: |
| QMessageBox.information(self, "Save", "No motion segments found. Add keyframes with ‘🎯 End Segment’.") |
| self._set_instruction("Record at least one segment on a layer, then Save.") |
| return |
|
|
| frames_out = composite_frames(background, all_layer_frames) |
|
|
| |
| mask_frames = [] |
| for t in range(T_total): |
| m = np.zeros((H, W), dtype=np.uint16) |
| for Lframes in all_layer_frames: |
| m += Lframes[t][:, :, 3].astype(np.uint16) |
| m = np.clip(m, 0, 255).astype(np.uint8) |
| mask_frames.append(m) |
|
|
| |
| try: |
| |
| cv2.imwrite(first_frame_path, self.canvas.base_bgr) |
|
|
| |
| save_video_mp4(frames_out, motion_path, fps=fps) |
|
|
| |
| save_video_mp4([cv2.cvtColor(m, cv2.COLOR_GRAY2BGR) for m in mask_frames], mask_path, fps=fps) |
|
|
| |
| if False: |
| |
| Vmax = 0 |
| for P in layer_polys: |
| if P.size: |
| Vmax = max(Vmax, P.shape[1]) |
|
|
| def pad_poly(P: np.ndarray, Vmax_: int) -> np.ndarray: |
| if P.size == 0: |
| return np.zeros((T_total, Vmax_, 2), dtype=np.float32) |
| T_, V, _ = P.shape |
| out = np.zeros((T_, Vmax_, 2), dtype=np.float32) |
| out[:, :V, :] = P |
| if V > 0: |
| out[:, V:, :] = P[:, V-1:V, :] |
| return out |
|
|
| polys_uniform = np.stack([pad_poly(P, Vmax) for P in layer_polys], axis=0) |
| np.save(npy_path, polys_uniform) |
|
|
| except Exception as e: |
| QMessageBox.critical(self, "Save", f"Failed to save:\n{e}") |
| return |
|
|
| QMessageBox.information(self, "Saved", f"Saved to:\n{final_dir}") |
| self._set_instruction("Saved! You can keep editing, play demo again, or start a New project.") |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| if sys.version_info < (3, 8): |
| print("[Warning] PySide6 officially supports Python 3.8+. You're on %d.%d." % (sys.version_info.major, sys.version_info.minor)) |
| app = QApplication(sys.argv) |
| w = MainWindow() |
| w.show() |
| sys.exit(app.exec()) |
|
|
| if __name__ == "__main__": |
| main() |
|
|