Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """
- Beat Scripter GUI
- =================
- A Tkinter wrapper around the BeatScripter workflow from the original Jupyter notebook.
- Workflow (one tab per step):
- 1. Setup - pick the video, choose start times, frames-per-section, model.
- 2. Box Editor - draw or type the bounding box on a sample frame.
- 3. Annotate - mark each frame as beat / not-beat (Space + B shortcuts + buttons).
- 4. Train / Predict - fit the classifier, then predict beats across the video.
- 5. Export - write the .funscript file.
- Dependencies:
- pip install opencv-python numpy pillow scikit-learn
- Run:
- python beat_scripter_gui.py
- """
- import json
- import os
- import queue
- import threading
- from pathlib import Path
- import cv2
- import numpy as np
- from PIL import Image, ImageTk
- import tkinter as tk
- from tkinter import ttk, filedialog, messagebox
- from sklearn.ensemble import RandomForestClassifier
- from sklearn.neighbors import KNeighborsClassifier
- from sklearn.svm import SVC
- # ---------------------------------------------------------------------------
- # Core logic (re-packaged from the original notebook, GUI-free)
- # ---------------------------------------------------------------------------
- class BeatScripter:
- """Headless version of the original BeatScripter class.
- GUI-dependent methods (cv2.imshow, waitKey, beat_marker) have been removed —
- annotation happens in the Tkinter UI instead.
- """
- MODELS = {
- "KNN (k=5)": lambda: KNeighborsClassifier(n_neighbors=5),
- "SVM (RBF)": lambda: SVC(),
- "Random Forest": lambda: RandomForestClassifier(),
- }
- def __init__(self, file_path, output_path=None):
- self.file_path = file_path
- self.file_path_out = output_path or str(Path(file_path).with_suffix(".funscript"))
- self.box_top_corner = (0, 0)
- self.box_size = (50, 50)
- self.frames_list = [] # list of sections; each section is list of (i, frame, time_ms)
- self.beats_trained_list = [] # list of sections; each section is list of (i, beat_bool)
- self.model = KNeighborsClassifier(n_neighbors=5)
- self.predicted_beats = []
- # -- model -------------------------------------------------------------
- def set_model(self, name):
- factory = self.MODELS.get(name)
- if factory is not None:
- self.model = factory()
- # -- loading -----------------------------------------------------------
- def load_frames(self, start_time_in_msec, number_of_frames):
- if not os.path.isfile(self.file_path):
- raise FileNotFoundError(f"Video file not found: {self.file_path}")
- cap = cv2.VideoCapture(self.file_path)
- if not cap.isOpened():
- raise RuntimeError(f"Could not open video: {self.file_path}")
- if start_time_in_msec > 0:
- cap.set(cv2.CAP_PROP_POS_MSEC, float(start_time_in_msec))
- frames = []
- while len(frames) < number_of_frames:
- ok, frame = cap.read()
- if not ok:
- break
- # POS_FRAMES is index of the NEXT frame to read; subtract 1 for the frame just returned
- i = int(cap.get(cv2.CAP_PROP_POS_FRAMES)) - 1
- t = cap.get(cv2.CAP_PROP_POS_MSEC)
- frames.append((i, frame, t))
- cap.release()
- return frames
- def load_frames_list(self, start_times_sec, number_of_frames=100, progress_cb=None):
- self.frames_list = []
- for idx, start_time in enumerate(start_times_sec):
- if progress_cb:
- progress_cb("section_start", idx, len(start_times_sec))
- frames = self.load_frames(start_time * 1000, number_of_frames)
- self.frames_list.append(frames)
- if progress_cb:
- progress_cb("section_done", idx, len(start_times_sec), len(frames))
- # -- box --------------------------------------------------------------
- def get_sub_frame(self, frame):
- x, y = self.box_top_corner
- w, h = self.box_size
- return frame[y:y + h, x:x + w, :]
- # -- training ---------------------------------------------------------
- def train(self):
- beats_train = []
- frames_train = []
- for b in self.beats_trained_list:
- beats_train += b
- for f in self.frames_list:
- frames_train += f
- # Build a fast index from frame_id -> frame so we don't do O(n*m) lookups.
- frame_by_id = {i: frame for i, frame, _t in frames_train}
- X, y = [], []
- for i, beat in beats_train:
- if i in frame_by_id:
- X.append(self.get_sub_frame(frame_by_id[i]).flatten())
- y.append(int(beat))
- if not X:
- raise ValueError("No training examples found — did you finish annotating?")
- self.model.fit(X, y)
- return len(X)
- # -- prediction -------------------------------------------------------
- def predict_beats(self, number=None, progress_cb=None, cancel_flag=None):
- cap = cv2.VideoCapture(self.file_path)
- if not cap.isOpened():
- raise RuntimeError(f"Could not open video: {self.file_path}")
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
- if number is None or number <= 0:
- number = total
- else:
- number = min(number, total)
- beats = []
- for i in range(number):
- if cancel_flag is not None and cancel_flag.is_set():
- break
- ok, frame = cap.read()
- if not ok:
- break
- try:
- sub = self.get_sub_frame(frame)
- if sub.size == 0:
- continue
- X_pred = [sub.flatten()]
- pred = bool(self.model.predict(X_pred)[0] == 1)
- beats.append((pred, i, cap.get(cv2.CAP_PROP_POS_MSEC)))
- except Exception:
- break
- if progress_cb and (i % 25 == 0 or i == number - 1):
- progress_cb(i + 1, number)
- cap.release()
- self.predicted_beats = beats
- return len(beats)
- # -- export -----------------------------------------------------------
- def export_funscript(self):
- actions = []
- pos = 0
- last_i = 0
- for beat, i, time in self.predicted_beats:
- if beat and (i - last_i) > 4:
- actions.append({"pos": pos, "at": int(round(time))})
- pos = 100 - pos
- last_i = i
- payload = {
- "version": "1.0",
- "inverted": False,
- "range": 90,
- "actions": actions,
- }
- with open(self.file_path_out, "w") as fh:
- json.dump(payload, fh)
- return len(actions)
- # ---------------------------------------------------------------------------
- # GUI
- # ---------------------------------------------------------------------------
- DISPLAY_MAX_W = 960
- DISPLAY_MAX_H = 540
- def cv_to_photo(cv_frame, box=None, max_w=DISPLAY_MAX_W, max_h=DISPLAY_MAX_H):
- """Convert a BGR OpenCV frame to a Tk PhotoImage, optionally with a red box.
- Returns (PhotoImage, scale, display_w, display_h).
- """
- h, w = cv_frame.shape[:2]
- scale = min(max_w / w, max_h / h, 1.0)
- new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale))
- img = cv_frame.copy()
- if box is not None:
- x, y, bw, bh = box
- cv2.rectangle(img, (x, y), (x + bw, y + bh), (0, 0, 255), 2)
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
- img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
- photo = ImageTk.PhotoImage(Image.fromarray(img))
- return photo, scale, new_w, new_h
- class BeatScripterGUI(tk.Tk):
- def __init__(self):
- super().__init__()
- self.title("Beat Scripter GUI")
- self.geometry("1180x820")
- self.minsize(900, 700)
- self.scripter: BeatScripter | None = None
- self.event_queue = queue.Queue()
- self.cancel_flag = threading.Event()
- self.worker_thread = None
- # Annotation runtime state
- self.ann_set_idx = 0
- self.ann_frame_idx = 0
- # Per-section annotation list: list of [(i, bool), ...] aligned with frames_list sections.
- self.annotations = []
- # Box editor runtime state
- self.box_canvas_scale = 1.0
- self.box_drag_start = None
- self.box_drag_rect = None
- self._build_ui()
- self._refresh_status("Ready. Pick a video to begin.")
- # Poll for worker events
- self.after(100, self._poll_queue)
- # Keyboard shortcuts for annotation — handled globally but gated by current tab
- self.bind_all("<space>", self._kb_space)
- self.bind_all("<KeyPress-b>", self._kb_b)
- self.bind_all("<KeyPress-B>", self._kb_b)
- self.bind_all("<Left>", self._kb_back)
- # ---------------------------------------------------------------- UI
- def _build_ui(self):
- self.notebook = ttk.Notebook(self)
- self.notebook.pack(fill="both", expand=True, padx=8, pady=8)
- self.tab_setup = ttk.Frame(self.notebook)
- self.tab_box = ttk.Frame(self.notebook)
- self.tab_annotate = ttk.Frame(self.notebook)
- self.tab_predict = ttk.Frame(self.notebook)
- self.tab_export = ttk.Frame(self.notebook)
- self.notebook.add(self.tab_setup, text="1. Setup")
- self.notebook.add(self.tab_box, text="2. Box Editor")
- self.notebook.add(self.tab_annotate, text="3. Annotate")
- self.notebook.add(self.tab_predict, text="4. Train / Predict")
- self.notebook.add(self.tab_export, text="5. Export")
- self._build_setup_tab()
- self._build_box_tab()
- self._build_annotate_tab()
- self._build_predict_tab()
- self._build_export_tab()
- self.status_var = tk.StringVar(value="")
- ttk.Separator(self, orient="horizontal").pack(side="bottom", fill="x")
- ttk.Label(self, textvariable=self.status_var, anchor="w", padding=(8, 4)).pack(
- side="bottom", fill="x"
- )
- # -- Setup tab ---------------------------------------------------------
- def _build_setup_tab(self):
- f = self.tab_setup
- for i in range(2):
- f.columnconfigure(i, weight=1)
- # Video path
- ttk.Label(f, text="Video file (.mp4):").grid(row=0, column=0, sticky="w", padx=8, pady=(12, 2))
- row1 = ttk.Frame(f)
- row1.grid(row=1, column=0, columnspan=2, sticky="ew", padx=8)
- row1.columnconfigure(0, weight=1)
- self.video_path_var = tk.StringVar()
- ttk.Entry(row1, textvariable=self.video_path_var).grid(row=0, column=0, sticky="ew")
- ttk.Button(row1, text="Browse...", command=self._pick_video).grid(row=0, column=1, padx=(6, 0))
- # Output path
- ttk.Label(f, text="Output .funscript path:").grid(row=2, column=0, sticky="w", padx=8, pady=(12, 2))
- row2 = ttk.Frame(f)
- row2.grid(row=3, column=0, columnspan=2, sticky="ew", padx=8)
- row2.columnconfigure(0, weight=1)
- self.output_path_var = tk.StringVar()
- ttk.Entry(row2, textvariable=self.output_path_var).grid(row=0, column=0, sticky="ew")
- ttk.Button(row2, text="Save as...", command=self._pick_output).grid(row=0, column=1, padx=(6, 0))
- # Start times
- ttk.Label(f, text="Start times (seconds, comma-separated):").grid(
- row=4, column=0, sticky="w", padx=8, pady=(12, 2)
- )
- self.start_times_var = tk.StringVar(value="0, 90, 300, 540")
- ttk.Entry(f, textvariable=self.start_times_var).grid(
- row=5, column=0, columnspan=2, sticky="ew", padx=8
- )
- ttk.Label(
- f,
- text="Tip: 0, 1.5*60, 5*60, 9*60 → 0, 90, 300, 540. Decimals allowed.",
- foreground="#666",
- ).grid(row=6, column=0, columnspan=2, sticky="w", padx=8)
- # Frames per section
- ttk.Label(f, text="Frames per section:").grid(row=7, column=0, sticky="w", padx=8, pady=(12, 2))
- self.frames_per_section_var = tk.IntVar(value=100)
- ttk.Spinbox(f, from_=10, to=2000, increment=10, textvariable=self.frames_per_section_var, width=10).grid(
- row=8, column=0, sticky="w", padx=8
- )
- # Model picker
- ttk.Label(f, text="Classifier:").grid(row=7, column=1, sticky="w", padx=8, pady=(12, 2))
- self.model_var = tk.StringVar(value="KNN (k=5)")
- ttk.Combobox(
- f,
- textvariable=self.model_var,
- values=list(BeatScripter.MODELS.keys()),
- state="readonly",
- width=20,
- ).grid(row=8, column=1, sticky="w", padx=8)
- # Action row
- action_row = ttk.Frame(f)
- action_row.grid(row=9, column=0, columnspan=2, sticky="ew", padx=8, pady=(20, 4))
- ttk.Button(action_row, text="Load Frames", command=self._on_load_frames).pack(side="left")
- self.load_progress = ttk.Progressbar(action_row, mode="determinate", length=300)
- self.load_progress.pack(side="left", padx=12)
- self.load_info_var = tk.StringVar()
- ttk.Label(action_row, textvariable=self.load_info_var).pack(side="left")
- # -- Box tab -----------------------------------------------------------
- def _build_box_tab(self):
- f = self.tab_box
- f.columnconfigure(0, weight=1)
- f.rowconfigure(1, weight=1)
- # Controls row
- ctrl = ttk.Frame(f)
- ctrl.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
- ttk.Label(ctrl, text="Section:").pack(side="left")
- self.box_set_var = tk.StringVar()
- self.box_set_combo = ttk.Combobox(
- ctrl, textvariable=self.box_set_var, state="readonly", width=22
- )
- self.box_set_combo.pack(side="left", padx=(4, 12))
- self.box_set_combo.bind("<<ComboboxSelected>>", lambda *_: self._render_box_frame())
- ttk.Label(ctrl, text="Frame:").pack(side="left")
- self.box_frame_var = tk.IntVar(value=0)
- self.box_frame_scale = ttk.Scale(
- ctrl,
- from_=0,
- to=0,
- orient="horizontal",
- length=220,
- variable=self.box_frame_var,
- command=lambda *_: self._render_box_frame(),
- )
- self.box_frame_scale.pack(side="left", padx=(4, 12))
- ttk.Label(ctrl, text="x:").pack(side="left")
- self.box_x_var = tk.IntVar(value=0)
- ttk.Spinbox(ctrl, from_=0, to=9999, textvariable=self.box_x_var, width=6).pack(side="left")
- ttk.Label(ctrl, text="y:").pack(side="left", padx=(8, 0))
- self.box_y_var = tk.IntVar(value=0)
- ttk.Spinbox(ctrl, from_=0, to=9999, textvariable=self.box_y_var, width=6).pack(side="left")
- ttk.Label(ctrl, text="w:").pack(side="left", padx=(8, 0))
- self.box_w_var = tk.IntVar(value=50)
- ttk.Spinbox(ctrl, from_=1, to=9999, textvariable=self.box_w_var, width=6).pack(side="left")
- ttk.Label(ctrl, text="h:").pack(side="left", padx=(8, 0))
- self.box_h_var = tk.IntVar(value=50)
- ttk.Spinbox(ctrl, from_=1, to=9999, textvariable=self.box_h_var, width=6).pack(side="left")
- ttk.Button(ctrl, text="Apply", command=self._apply_box).pack(side="left", padx=(12, 0))
- ttk.Label(
- ctrl,
- text="(click + drag on the image to draw a new box)",
- foreground="#666",
- ).pack(side="left", padx=(8, 0))
- # Canvas
- canvas_wrap = ttk.Frame(f, relief="sunken", borderwidth=1)
- canvas_wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
- self.box_canvas = tk.Canvas(canvas_wrap, background="#222", highlightthickness=0)
- self.box_canvas.pack(fill="both", expand=True)
- self.box_canvas.bind("<ButtonPress-1>", self._box_canvas_press)
- self.box_canvas.bind("<B1-Motion>", self._box_canvas_drag)
- self.box_canvas.bind("<ButtonRelease-1>", self._box_canvas_release)
- # -- Annotate tab ------------------------------------------------------
- def _build_annotate_tab(self):
- f = self.tab_annotate
- f.columnconfigure(0, weight=1)
- f.rowconfigure(1, weight=1)
- info = ttk.Frame(f)
- info.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
- self.ann_info_var = tk.StringVar(value="Load frames and set a box first.")
- ttk.Label(info, textvariable=self.ann_info_var, font=("TkDefaultFont", 11, "bold")).pack(side="left")
- self.ann_progress = ttk.Progressbar(info, mode="determinate", length=280)
- self.ann_progress.pack(side="right")
- canvas_wrap = ttk.Frame(f, relief="sunken", borderwidth=1)
- canvas_wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
- self.ann_canvas = tk.Canvas(canvas_wrap, background="#222", highlightthickness=0)
- self.ann_canvas.pack(fill="both", expand=True)
- btns = ttk.Frame(f)
- btns.grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 12))
- ttk.Button(btns, text="◀ Back (←)", command=self._ann_back).pack(side="left")
- ttk.Button(btns, text="Not Beat (Space)", command=lambda: self._ann_mark(False)).pack(
- side="left", padx=(8, 0)
- )
- ttk.Button(btns, text="Beat (B)", command=lambda: self._ann_mark(True)).pack(side="left", padx=(8, 0))
- ttk.Button(btns, text="Skip Section", command=self._ann_skip_section).pack(side="left", padx=(8, 0))
- ttk.Label(
- btns,
- text="Space = Not Beat, B = Beat, ← = Back",
- foreground="#666",
- ).pack(side="right")
- # -- Predict tab -------------------------------------------------------
- def _build_predict_tab(self):
- f = self.tab_predict
- for i in range(2):
- f.columnconfigure(i, weight=1)
- train_box = ttk.LabelFrame(f, text="Train")
- train_box.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=8)
- ttk.Button(train_box, text="Train Classifier", command=self._on_train).pack(side="left", padx=8, pady=8)
- self.train_status_var = tk.StringVar(value="Not trained.")
- ttk.Label(train_box, textvariable=self.train_status_var).pack(side="left", padx=(8, 8))
- pred_box = ttk.LabelFrame(f, text="Predict")
- pred_box.grid(row=1, column=0, columnspan=2, sticky="ew", padx=8, pady=8)
- ttk.Label(pred_box, text="Number of frames (blank = all):").grid(
- row=0, column=0, sticky="w", padx=8, pady=8
- )
- self.predict_n_var = tk.StringVar(value="15000")
- ttk.Entry(pred_box, textvariable=self.predict_n_var, width=10).grid(row=0, column=1, sticky="w", padx=8)
- self.predict_btn = ttk.Button(pred_box, text="Predict Beats", command=self._on_predict)
- self.predict_btn.grid(row=1, column=0, sticky="w", padx=8, pady=(0, 8))
- self.cancel_predict_btn = ttk.Button(
- pred_box, text="Cancel", command=self._cancel_predict, state="disabled"
- )
- self.cancel_predict_btn.grid(row=1, column=1, sticky="w", padx=8, pady=(0, 8))
- self.predict_progress = ttk.Progressbar(pred_box, mode="determinate", length=560)
- self.predict_progress.grid(row=2, column=0, columnspan=2, sticky="ew", padx=8, pady=(0, 4))
- self.predict_info_var = tk.StringVar()
- ttk.Label(pred_box, textvariable=self.predict_info_var).grid(
- row=3, column=0, columnspan=2, sticky="w", padx=8, pady=(0, 8)
- )
- # -- Export tab --------------------------------------------------------
- def _build_export_tab(self):
- f = self.tab_export
- f.columnconfigure(0, weight=1)
- ttk.Label(f, text="Output .funscript path:").grid(row=0, column=0, sticky="w", padx=8, pady=(12, 2))
- row = ttk.Frame(f)
- row.grid(row=1, column=0, sticky="ew", padx=8)
- row.columnconfigure(0, weight=1)
- # We just mirror the same variable used on the Setup tab
- ttk.Entry(row, textvariable=self.output_path_var).grid(row=0, column=0, sticky="ew")
- ttk.Button(row, text="Save as...", command=self._pick_output).grid(row=0, column=1, padx=(6, 0))
- self.export_summary_var = tk.StringVar(value="Run prediction first.")
- ttk.Label(f, textvariable=self.export_summary_var).grid(
- row=2, column=0, sticky="w", padx=8, pady=(16, 4)
- )
- btn_row = ttk.Frame(f)
- btn_row.grid(row=3, column=0, sticky="w", padx=8, pady=(4, 8))
- ttk.Button(btn_row, text="Export .funscript", command=self._on_export).pack(side="left")
- ttk.Button(btn_row, text="Open output folder", command=self._open_output_folder).pack(
- side="left", padx=(8, 0)
- )
- # ---------------------------------------------------------------- Setup actions
- def _pick_video(self):
- path = filedialog.askopenfilename(
- title="Select a video file",
- filetypes=[("MP4 video", "*.mp4"), ("All video", "*.mp4 *.mov *.mkv *.avi"), ("All files", "*.*")],
- )
- if not path:
- return
- self.video_path_var.set(path)
- # Default output to same folder with .funscript extension
- out = str(Path(path).with_suffix(".funscript"))
- self.output_path_var.set(out)
- self._refresh_status(f"Selected video: {path}")
- def _pick_output(self):
- path = filedialog.asksaveasfilename(
- title="Save funscript as",
- defaultextension=".funscript",
- filetypes=[("Funscript", "*.funscript"), ("JSON", "*.json"), ("All files", "*.*")],
- )
- if path:
- self.output_path_var.set(path)
- def _parse_start_times(self):
- raw = self.start_times_var.get()
- out = []
- for tok in raw.split(","):
- tok = tok.strip()
- if not tok:
- continue
- # Allow simple "a*b" expressions like 1.5*60
- try:
- if "*" in tok:
- parts = [float(p.strip()) for p in tok.split("*")]
- val = 1.0
- for p in parts:
- val *= p
- else:
- val = float(tok)
- out.append(val)
- except ValueError:
- raise ValueError(f"Could not parse start time '{tok}'")
- if not out:
- raise ValueError("At least one start time is required.")
- return out
- def _on_load_frames(self):
- video = self.video_path_var.get().strip()
- if not video:
- messagebox.showerror("No video", "Pick a video file first.")
- return
- if not os.path.isfile(video):
- messagebox.showerror("Missing file", f"Video file not found:\n{video}")
- return
- try:
- start_times = self._parse_start_times()
- except ValueError as exc:
- messagebox.showerror("Bad start times", str(exc))
- return
- try:
- n_frames = int(self.frames_per_section_var.get())
- except Exception:
- messagebox.showerror("Bad frames count", "Frames per section must be an integer.")
- return
- out = self.output_path_var.get().strip() or None
- self.scripter = BeatScripter(video, output_path=out)
- self.scripter.set_model(self.model_var.get())
- self.load_progress["value"] = 0
- self.load_progress["maximum"] = len(start_times)
- self.load_info_var.set("Loading...")
- self._refresh_status("Loading frames in background...")
- def worker():
- def cb(*args):
- self.event_queue.put(("load_progress",) + args)
- try:
- self.scripter.load_frames_list(start_times, n_frames, progress_cb=cb)
- self.event_queue.put(("load_done", None))
- except Exception as exc:
- self.event_queue.put(("load_error", str(exc)))
- self.worker_thread = threading.Thread(target=worker, daemon=True)
- self.worker_thread.start()
- def _on_load_done(self):
- if not self.scripter or not self.scripter.frames_list:
- return
- # Populate Box Editor section combo
- section_labels = [
- f"Section {idx + 1} ({len(s)} frames)" for idx, s in enumerate(self.scripter.frames_list)
- ]
- self.box_set_combo["values"] = section_labels
- if section_labels:
- self.box_set_combo.current(0)
- first_section_len = len(self.scripter.frames_list[0]) if self.scripter.frames_list else 0
- self.box_frame_scale.configure(from_=0, to=max(0, first_section_len - 1))
- self.box_frame_var.set(0)
- # Initialize annotations storage to empty for each section
- self.annotations = [[] for _ in self.scripter.frames_list]
- self.ann_set_idx = 0
- self.ann_frame_idx = 0
- # Render first frame in box editor
- self._render_box_frame()
- total = sum(len(s) for s in self.scripter.frames_list)
- self.load_info_var.set(f"Loaded {total} frames in {len(self.scripter.frames_list)} section(s).")
- self._refresh_status("Frames loaded. Set a box, then go to Annotate.")
- self.notebook.select(self.tab_box)
- # ---------------------------------------------------------------- Box editor
- def _selected_box_section_index(self):
- sel = self.box_set_combo.current()
- return max(0, sel)
- def _render_box_frame(self):
- if not self.scripter or not self.scripter.frames_list:
- return
- sec = self._selected_box_section_index()
- if sec >= len(self.scripter.frames_list):
- return
- section = self.scripter.frames_list[sec]
- if not section:
- return
- idx = int(self.box_frame_var.get())
- idx = max(0, min(idx, len(section) - 1))
- _, frame, _ = section[idx]
- # Update scale based on current spinbox sizes
- self.box_frame_scale.configure(to=len(section) - 1)
- box = (
- int(self.box_x_var.get()),
- int(self.box_y_var.get()),
- int(self.box_w_var.get()),
- int(self.box_h_var.get()),
- )
- photo, scale, w, h = cv_to_photo(frame, box=box)
- self.box_canvas.config(width=w, height=h)
- self.box_canvas.delete("all")
- self.box_canvas.create_image(0, 0, anchor="nw", image=photo, tags=("img",))
- self.box_canvas.image = photo # keep ref
- self.box_canvas_scale = scale
- def _box_canvas_press(self, event):
- self.box_drag_start = (event.x, event.y)
- if self.box_drag_rect is not None:
- self.box_canvas.delete(self.box_drag_rect)
- self.box_drag_rect = self.box_canvas.create_rectangle(
- event.x, event.y, event.x, event.y, outline="yellow", width=2
- )
- def _box_canvas_drag(self, event):
- if self.box_drag_start is None or self.box_drag_rect is None:
- return
- x0, y0 = self.box_drag_start
- self.box_canvas.coords(self.box_drag_rect, x0, y0, event.x, event.y)
- def _box_canvas_release(self, event):
- if self.box_drag_start is None:
- return
- x0, y0 = self.box_drag_start
- x1, y1 = event.x, event.y
- self.box_drag_start = None
- scale = self.box_canvas_scale or 1.0
- # Convert display coords to real coords
- rx0, ry0 = int(min(x0, x1) / scale), int(min(y0, y1) / scale)
- rw, rh = max(1, int(abs(x1 - x0) / scale)), max(1, int(abs(y1 - y0) / scale))
- self.box_x_var.set(rx0)
- self.box_y_var.set(ry0)
- self.box_w_var.set(rw)
- self.box_h_var.set(rh)
- self._apply_box()
- def _apply_box(self):
- if not self.scripter:
- return
- try:
- x = int(self.box_x_var.get())
- y = int(self.box_y_var.get())
- w = max(1, int(self.box_w_var.get()))
- h = max(1, int(self.box_h_var.get()))
- except Exception:
- messagebox.showerror("Bad box", "Box coordinates must be integers.")
- return
- self.scripter.box_top_corner = (x, y)
- self.scripter.box_size = (w, h)
- self._render_box_frame()
- self._refresh_status(f"Box set to top=({x},{y}), size=({w}x{h}).")
- # ---------------------------------------------------------------- Annotate
- def _on_tab_change(self, *_):
- pass
- def _is_annotate_tab(self):
- try:
- return self.notebook.index(self.notebook.select()) == 2
- except Exception:
- return False
- def _kb_space(self, _evt):
- if not self._is_annotate_tab():
- return
- if isinstance(self.focus_get(), (ttk.Entry, tk.Entry, ttk.Spinbox, ttk.Combobox)):
- return
- self._ann_mark(False)
- def _kb_b(self, _evt):
- if not self._is_annotate_tab():
- return
- if isinstance(self.focus_get(), (ttk.Entry, tk.Entry, ttk.Spinbox, ttk.Combobox)):
- return
- self._ann_mark(True)
- def _kb_back(self, _evt):
- if not self._is_annotate_tab():
- return
- if isinstance(self.focus_get(), (ttk.Entry, tk.Entry, ttk.Spinbox, ttk.Combobox)):
- return
- self._ann_back()
- def _ann_total_progress(self):
- if not self.scripter:
- return 0, 0
- total = sum(len(s) for s in self.scripter.frames_list)
- done = sum(len(a) for a in self.annotations)
- return done, total
- def _render_ann_frame(self):
- if not self.scripter or not self.scripter.frames_list:
- self.ann_info_var.set("Load frames first.")
- return
- if self.ann_set_idx >= len(self.scripter.frames_list):
- self.ann_info_var.set("All sections annotated. Go to Train / Predict.")
- self.ann_canvas.delete("all")
- return
- section = self.scripter.frames_list[self.ann_set_idx]
- if self.ann_frame_idx >= len(section):
- # Move to next section
- self.ann_set_idx += 1
- self.ann_frame_idx = 0
- self._render_ann_frame()
- return
- _, frame, _ = section[self.ann_frame_idx]
- box = (
- self.scripter.box_top_corner[0],
- self.scripter.box_top_corner[1],
- self.scripter.box_size[0],
- self.scripter.box_size[1],
- )
- photo, _, w, h = cv_to_photo(frame, box=box)
- self.ann_canvas.config(width=w, height=h)
- self.ann_canvas.delete("all")
- self.ann_canvas.create_image(0, 0, anchor="nw", image=photo)
- self.ann_canvas.image = photo
- done, total = self._ann_total_progress()
- self.ann_progress["maximum"] = max(total, 1)
- self.ann_progress["value"] = done
- self.ann_info_var.set(
- f"Section {self.ann_set_idx + 1}/{len(self.scripter.frames_list)} · "
- f"Frame {self.ann_frame_idx + 1}/{len(section)} · "
- f"Annotated {done}/{total}"
- )
- def _ann_mark(self, beat: bool):
- if not self.scripter or not self.scripter.frames_list:
- return
- if self.ann_set_idx >= len(self.scripter.frames_list):
- return
- section = self.scripter.frames_list[self.ann_set_idx]
- if self.ann_frame_idx >= len(section):
- return
- i, _, _ = section[self.ann_frame_idx]
- # Make sure we don't double-record after Back
- sect_ann = self.annotations[self.ann_set_idx]
- # Trim any future annotations (if user went back then re-marked)
- sect_ann[:] = sect_ann[: self.ann_frame_idx]
- sect_ann.append((i, beat))
- self.ann_frame_idx += 1
- self._render_ann_frame()
- def _ann_back(self):
- if self.ann_frame_idx > 0:
- self.ann_frame_idx -= 1
- elif self.ann_set_idx > 0:
- self.ann_set_idx -= 1
- self.ann_frame_idx = len(self.scripter.frames_list[self.ann_set_idx]) - 1
- # Remove the annotation we're going back over so the next mark overwrites cleanly
- sect_ann = self.annotations[self.ann_set_idx]
- if len(sect_ann) > self.ann_frame_idx:
- del sect_ann[self.ann_frame_idx:]
- self._render_ann_frame()
- def _ann_skip_section(self):
- if not self.scripter:
- return
- self.ann_set_idx += 1
- self.ann_frame_idx = 0
- self._render_ann_frame()
- # ---------------------------------------------------------------- Predict
- def _on_train(self):
- if not self.scripter:
- messagebox.showerror("No data", "Load frames first.")
- return
- # Copy current annotations to the scripter
- self.scripter.beats_trained_list = [list(a) for a in self.annotations]
- self.scripter.set_model(self.model_var.get())
- try:
- n = self.scripter.train()
- except Exception as exc:
- messagebox.showerror("Training failed", str(exc))
- self.train_status_var.set("Training failed.")
- return
- self.train_status_var.set(f"Trained on {n} annotated frames.")
- self._refresh_status(f"Model trained on {n} frames.")
- def _on_predict(self):
- if not self.scripter:
- messagebox.showerror("No data", "Load frames and train first.")
- return
- raw = self.predict_n_var.get().strip()
- n = None
- if raw:
- try:
- n = int(raw)
- except ValueError:
- messagebox.showerror("Bad number", "Number of frames must be an integer (or empty).")
- return
- self.cancel_flag.clear()
- self.predict_progress["value"] = 0
- self.predict_progress["maximum"] = max(n or 1, 1)
- self.predict_info_var.set("Predicting...")
- self.predict_btn.configure(state="disabled")
- self.cancel_predict_btn.configure(state="normal")
- def worker():
- def cb(done, total):
- self.event_queue.put(("predict_progress", done, total))
- try:
- count = self.scripter.predict_beats(
- number=n, progress_cb=cb, cancel_flag=self.cancel_flag
- )
- self.event_queue.put(("predict_done", count))
- except Exception as exc:
- self.event_queue.put(("predict_error", str(exc)))
- self.worker_thread = threading.Thread(target=worker, daemon=True)
- self.worker_thread.start()
- def _cancel_predict(self):
- self.cancel_flag.set()
- self._refresh_status("Cancelling prediction...")
- # ---------------------------------------------------------------- Export
- def _on_export(self):
- if not self.scripter:
- messagebox.showerror("No data", "Run prediction first.")
- return
- if not self.scripter.predicted_beats:
- messagebox.showerror("No predictions", "There are no predicted beats to export.")
- return
- out = self.output_path_var.get().strip()
- if not out:
- messagebox.showerror("No output path", "Pick an output .funscript path.")
- return
- self.scripter.file_path_out = out
- try:
- n = self.scripter.export_funscript()
- except Exception as exc:
- messagebox.showerror("Export failed", str(exc))
- return
- self.export_summary_var.set(f"Wrote {n} actions to {out}")
- self._refresh_status(f"Funscript exported: {out}")
- messagebox.showinfo("Exported", f"Wrote {n} actions to:\n{out}")
- def _open_output_folder(self):
- out = self.output_path_var.get().strip()
- if not out:
- return
- folder = str(Path(out).parent)
- try:
- if os.name == "nt":
- os.startfile(folder) # type: ignore[attr-defined]
- elif os.name == "posix":
- import subprocess
- subprocess.Popen(["xdg-open", folder])
- except Exception as exc:
- messagebox.showerror("Open folder failed", str(exc))
- # ---------------------------------------------------------------- Queue
- def _poll_queue(self):
- try:
- while True:
- event = self.event_queue.get_nowait()
- self._handle_event(event)
- except queue.Empty:
- pass
- self.after(80, self._poll_queue)
- def _handle_event(self, event):
- tag = event[0]
- if tag == "load_progress":
- kind = event[1]
- if kind == "section_done":
- idx = event[2]
- self.load_progress["value"] = idx + 1
- self.load_info_var.set(f"Loaded section {idx + 1}/{event[3]} ({event[4]} frames)")
- elif tag == "load_done":
- self._on_load_done()
- elif tag == "load_error":
- messagebox.showerror("Load failed", event[1])
- self._refresh_status("Load failed.")
- elif tag == "predict_progress":
- done, total = event[1], event[2]
- self.predict_progress["maximum"] = max(total, 1)
- self.predict_progress["value"] = done
- self.predict_info_var.set(f"{done}/{total} frames processed")
- elif tag == "predict_done":
- count = event[1]
- self.predict_info_var.set(f"Predicted on {count} frames.")
- self.predict_btn.configure(state="normal")
- self.cancel_predict_btn.configure(state="disabled")
- self.export_summary_var.set(
- f"{count} frames scored. Click Export .funscript to write the file."
- )
- self._refresh_status("Prediction complete. Ready to export.")
- elif tag == "predict_error":
- self.predict_btn.configure(state="normal")
- self.cancel_predict_btn.configure(state="disabled")
- self.predict_info_var.set("Prediction failed.")
- messagebox.showerror("Prediction failed", event[1])
- def _refresh_status(self, msg):
- self.status_var.set(msg)
- def main():
- app = BeatScripterGUI()
- # Re-render annotation view when switching tabs so it picks up box changes
- app.notebook.bind(
- "<<NotebookTabChanged>>",
- lambda *_: app._render_ann_frame() if app._is_annotate_tab() else None,
- )
- app.mainloop()
- if __name__ == "__main__":
- main()
Advertisement
Add Comment
Please, Sign In to add comment