|
|
| import sys
|
| import os
|
| import traceback
|
| import logging
|
| import threading
|
| from datetime import datetime
|
|
|
|
|
| if getattr(sys, 'frozen', False):
|
| _LOG_DIR = os.path.dirname(sys.executable)
|
| else:
|
| _LOG_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
| _LOG_PATH = os.path.join(_LOG_DIR, "log.txt")
|
| logger = logging.getLogger("MangaEditor")
|
| logger.setLevel(logging.DEBUG)
|
| _fh = logging.FileHandler(_LOG_PATH, mode='a', encoding='utf-8')
|
| _fh.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
|
| logger.addHandler(_fh)
|
|
|
| def get_memory_mb():
|
| """Lấy RAM process đang dùng (MB) trên Windows."""
|
| try:
|
| import ctypes
|
| from ctypes import wintypes
|
| class PROCESS_MEMORY_COUNTERS(ctypes.Structure):
|
| _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
|
| ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t),
|
| ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t),
|
| ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
| ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)]
|
| pmc = PROCESS_MEMORY_COUNTERS()
|
| pmc.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS)
|
| ctypes.windll.psapi.GetProcessMemoryInfo(
|
| ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(pmc), pmc.cb)
|
| return pmc.WorkingSetSize // (1024 * 1024)
|
| except:
|
| return -1
|
|
|
| def log_error(exc_type, exc_value, exc_tb):
|
| error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
| mem = get_memory_mb()
|
| logger.critical(f"UNHANDLED CRASH | RAM: {mem}MB\n{error_msg}")
|
| try:
|
| import ctypes
|
| ctypes.windll.user32.MessageBoxW(0,
|
| f"Tool crash! RAM={mem}MB. Xem chi tiết trong log.txt",
|
| "Lỗi Nghiêm Trọng", 0x10)
|
| except:
|
| pass
|
| sys.exit(1)
|
|
|
| sys.excepthook = log_error
|
|
|
|
|
| def _thread_except_hook(args):
|
| logger.error(f"THREAD CRASH [{args.thread.name}] | RAM: {get_memory_mb()}MB\n"
|
| + "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)))
|
| threading.excepthook = _thread_except_hook
|
|
|
| logger.info("=" * 60)
|
| logger.info(f"APP KHỞI ĐỘNG | PID={os.getpid()} | RAM={get_memory_mb()}MB")
|
| logger.info("=" * 60)
|
|
|
|
|
| os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
| os.environ["HF_HUB_OFFLINE"] = "1"
|
| os.environ["OMP_NUM_THREADS"] = "1"
|
| os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
| os.environ["MKL_NUM_THREADS"] = "1"
|
|
|
| import io
|
| import json
|
| import requests
|
| import cv2
|
| import numpy as np
|
| from PIL import Image, ImageDraw, ImageFont
|
| from collections import deque
|
| from datetime import timedelta
|
|
|
|
|
| if getattr(sys, 'frozen', False):
|
|
|
| BUNDLE_DIR = sys._MEIPASS
|
| EXE_DIR = os.path.dirname(sys.executable)
|
| else:
|
| BUNDLE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| EXE_DIR = BUNDLE_DIR
|
|
|
| CTD_DIR = os.path.join(BUNDLE_DIR, "comic-text-detector")
|
| STATE_FILE = os.path.join(EXE_DIR, "manga_queue_state.json")
|
| sys.path.insert(0, CTD_DIR)
|
| import torch
|
|
|
| try:
|
| from inference import TextDetector
|
| from utils.textmask import REFINEMASK_INPAINT
|
| except ImportError as e:
|
| raise RuntimeError(f"Cannot import comic-text-detector modules! Lỗi: {e}")
|
|
|
| from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
| QHBoxLayout, QPushButton, QLabel, QFileDialog,
|
| QMessageBox, QSpinBox, QGroupBox, QCheckBox,
|
| QListWidget, QListWidgetItem, QSlider, QScrollArea,
|
| QGridLayout, QSplitter, QShortcut, QSizePolicy,
|
| QFrame, QProgressBar)
|
| from PyQt5.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QKeySequence, QCursor, QBrush
|
| from PyQt5.QtCore import Qt, QThread, pyqtSignal, QRect, QPoint, QSize
|
|
|
|
|
|
|
| FONT_PATH = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
| def download_font():
|
| pass
|
|
|
|
|
|
|
|
|
| global_manga_ocr = None
|
|
|
| def init_worker_process(ctd_dir, bg_style):
|
| import os
|
| import sys
|
| import torch
|
| import cv2
|
|
|
| if sys.platform == 'win32':
|
| sys.stdout = open(os.devnull, 'w')
|
| sys.stderr = open(os.devnull, 'w')
|
| else:
|
| sys.stdout = open(os.devnull, 'w')
|
| sys.stderr = open(os.devnull, 'w')
|
|
|
| os.chdir(ctd_dir)
|
| torch.set_num_threads(1)
|
| cv2.setNumThreads(1)
|
|
|
| global global_manga_ocr
|
| if bg_style != 4:
|
| from manga_ocr import MangaOcr
|
| global_manga_ocr = MangaOcr()
|
| else:
|
| global_manga_ocr = None
|
|
|
| def process_frame_task(task_args):
|
| """Tiến trình con xử lý CPU Render độc lập"""
|
| (img_bgr, mask_refined_uint8, picklable_blk_list, draw_bubbles,
|
| bg_style, font_size, ignore_floating, frame_idx) = task_args
|
|
|
| import cv2
|
| import numpy as np
|
| from PIL import Image, ImageDraw, ImageFont
|
|
|
| FONT_PATH = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
|
|
| global global_manga_ocr
|
| manga_ocr = global_manga_ocr
|
|
|
| original_pil_img = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
|
| erased_img_bgr = img_bgr.copy()
|
|
|
| img_gray_global = cv2.cvtColor(erased_img_bgr, cv2.COLOR_BGR2GRAY)
|
| _, global_thresh = cv2.threshold(img_gray_global, 240, 255, cv2.THRESH_BINARY)
|
| global_contours, _ = cv2.findContours(global_thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
|
| img_h, img_w = img_bgr.shape[:2]
|
|
|
| if draw_bubbles and bg_style == 0:
|
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| mask_dilated = cv2.dilate(mask_refined_uint8, kernel, iterations=1)
|
|
|
| for pblk in picklable_blk_list:
|
| x1, y1, x2, y2 = map(int, pblk['xyxy'])
|
| pad = 12
|
| ix1, iy1 = max(0, x1-pad), max(0, y1-pad)
|
| ix2, iy2 = min(img_w, x2+pad), min(img_h, y2+pad)
|
|
|
| sub_img = erased_img_bgr[iy1:iy2, ix1:ix2]
|
| sub_mask = mask_dilated[iy1:iy2, ix1:ix2]
|
|
|
| if np.any(sub_mask):
|
| sub_erased = cv2.inpaint(sub_img, sub_mask, 5, cv2.INPAINT_TELEA)
|
| erased_img_bgr[iy1:iy2, ix1:ix2] = sub_erased
|
|
|
| result_img = Image.fromarray(cv2.cvtColor(erased_img_bgr, cv2.COLOR_BGR2RGB))
|
| draw = ImageDraw.Draw(result_img)
|
|
|
| transparent_pil = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0))
|
| transparent_draw = ImageDraw.Draw(transparent_pil)
|
|
|
| try:
|
| font = ImageFont.truetype(FONT_PATH, font_size)
|
| except:
|
| font = ImageFont.load_default()
|
|
|
| extracted_texts = []
|
| bboxes = []
|
|
|
| for i, pblk in enumerate(picklable_blk_list):
|
| bx1, by1, bx2, by2 = map(int, pblk['xyxy'])
|
| bboxes.append([bx1, by1, bx2, by2])
|
|
|
| if bg_style != 4:
|
| crop_box = (max(0, bx1-6), max(0, by1-6), min(img_w, bx2+6), min(img_h, by2+6))
|
| crop_full = original_pil_img.crop(crop_box)
|
| block_full_text = manga_ocr(crop_full)
|
| else:
|
| block_full_text = "[Tính năng Lấy Nền Bỏ Qua OCR để tăng tốc lấy khung]"
|
|
|
| cv_lines = []
|
| for pts in pblk['lines']:
|
| pts = np.array(pts)
|
| lx1, ly1 = int(pts[:, 0].min()), int(pts[:, 1].min())
|
| lx2, ly2 = int(pts[:, 0].max()), int(pts[:, 1].max())
|
| cv_lines.append({
|
| 'x': (lx1 + lx2) / 2,
|
| 'y': ly1,
|
| 'h': ly2 - ly1
|
| })
|
|
|
| cv_lines.sort(key=lambda item: item['x'], reverse=True)
|
|
|
| if bg_style != 4:
|
| total_h = sum(l['h'] for l in cv_lines)
|
| total_chars = len(block_full_text)
|
| char_idx = 0
|
|
|
| for idx, line in enumerate(cv_lines):
|
| if idx == len(cv_lines) - 1:
|
| line['text'] = block_full_text[char_idx:]
|
| else:
|
| num_chars = round(total_chars * (line['h'] / total_h)) if total_h > 0 else 0
|
| line['text'] = block_full_text[char_idx : char_idx + num_chars]
|
| char_idx += num_chars
|
|
|
| if draw_bubbles:
|
| fs = font_size
|
| if not cv_lines:
|
| continue
|
|
|
| w_left = min(l['x'] for l in cv_lines) - fs * 1.5
|
| w_right = max(l['x'] for l in cv_lines) + fs * 1.5
|
| h_top = min(l['y'] for l in cv_lines) - fs * 1.5
|
| h_bot = max(l['y'] + l['h'] for l in cv_lines) + fs * 1.5
|
|
|
| cx, cy = (w_left + w_right)/2, (h_top + h_bot)/2
|
| chosen_style = bg_style
|
|
|
| if chosen_style in (0, 3, 4):
|
| bubble_contour = None
|
| for c in global_contours:
|
| if cv2.pointPolygonTest(c, (float(cx), float(cy)), False) >= 0:
|
| area = cv2.contourArea(c)
|
| text_area = max(1, (h_bot - h_top) * (w_right - w_left))
|
| if area > text_area * 0.8:
|
| hull = cv2.convexHull(c)
|
| hull_area = cv2.contourArea(hull)
|
| solidity = area / hull_area if hull_area > 0 else 0
|
| if solidity > 0.6:
|
| bubble_contour = c
|
| if chosen_style == 3:
|
| _, _, wc, hc = cv2.boundingRect(c)
|
| chosen_style = 2 if (area / float(wc * hc)) > 0.85 else 1
|
| break
|
|
|
| if chosen_style in (0, 4):
|
| if bubble_contour is not None:
|
| bx, by, bw, bh = cv2.boundingRect(bubble_contour)
|
| pad_auto = 15
|
| y1_crop = max(0, by - pad_auto)
|
| y2_crop = min(img_h, by + bh + pad_auto)
|
| x1_crop = max(0, bx - pad_auto)
|
| x2_crop = min(img_w, bx + bw + pad_auto)
|
|
|
| src_img = img_bgr if chosen_style == 4 else erased_img_bgr
|
| crop_bgr = src_img[y1_crop:y2_crop, x1_crop:x2_crop]
|
| crop_rgba = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2BGRA)
|
|
|
| mask_bubble = np.zeros((y2_crop - y1_crop, x2_crop - x1_crop), dtype=np.uint8)
|
| shifted_contour = bubble_contour - [x1_crop, y1_crop]
|
| cv2.drawContours(mask_bubble, [shifted_contour], -1, 255, -1)
|
|
|
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
| mask_bubble = cv2.dilate(mask_bubble, kernel, iterations=1)
|
|
|
| mask_bubble = cv2.GaussianBlur(mask_bubble, (5, 5), 0)
|
| crop_rgba[:, :, 3] = mask_bubble
|
| else:
|
| if ignore_floating:
|
| continue
|
| pad_auto = 30
|
| y1_crop = max(0, int(h_top)-pad_auto)
|
| y2_crop = min(img_h, int(h_bot)+pad_auto)
|
| x1_crop = max(0, int(w_left)-pad_auto)
|
| x2_crop = min(img_w, int(w_right)+pad_auto)
|
|
|
| src_img = img_bgr if chosen_style == 4 else erased_img_bgr
|
| crop_bgr = src_img[y1_crop:y2_crop, x1_crop:x2_crop]
|
| crop_rgba = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2BGRA)
|
|
|
| text_mask_crop = mask_refined_uint8[y1_crop:y2_crop, x1_crop:x2_crop]
|
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| text_mask_dil = cv2.dilate(text_mask_crop, kernel, iterations=1)
|
| mask_bubble = cv2.GaussianBlur(text_mask_dil, (3, 3), 0)
|
| crop_rgba[:, :, 3] = mask_bubble
|
|
|
| bubble_pil = Image.fromarray(cv2.cvtColor(crop_rgba, cv2.COLOR_BGRA2RGBA))
|
| transparent_pil.paste(bubble_pil, (x1_crop, y1_crop), bubble_pil)
|
|
|
| if chosen_style == 1:
|
| rw = (w_right - w_left)/2 * 1.414 + 10
|
| rh = (h_bot - h_top)/2 * 1.414 + 10
|
| draw.ellipse([cx-rw-2, cy-rh-2, cx+rw+2, cy+rh+2], fill="black")
|
| draw.ellipse([cx-rw, cy-rh, cx+rw, cy+rh], fill="white")
|
| transparent_draw.ellipse([cx-rw-2, cy-rh-2, cx+rw+2, cy+rh+2], fill="black")
|
| transparent_draw.ellipse([cx-rw, cy-rh, cx+rw, cy+rh], fill="white")
|
| elif chosen_style == 2:
|
| draw.rounded_rectangle([w_left-10-2, h_top-20-2, w_right+10+2, h_bot+20+2], radius=15, fill="black")
|
| draw.rounded_rectangle([w_left-10, h_top-20, w_right+10, h_bot+20], radius=15, fill="white")
|
| transparent_draw.rounded_rectangle([w_left-10-2, h_top-20-2, w_right+10+2, h_bot+20+2], radius=15, fill="black")
|
| transparent_draw.rounded_rectangle([w_left-10, h_top-20, w_right+10, h_bot+20], radius=15, fill="white")
|
|
|
| if chosen_style != 4:
|
| for line in cv_lines:
|
| cursor_x = line['x'] - fs/2
|
| cursor_y = line['y']
|
|
|
| for char in line['text']:
|
| shift_x, shift_y = 0, 0
|
| if char in ('。', '、', '.', ','):
|
| shift_x, shift_y = fs * 0.5, -fs * 0.4
|
| draw.text((cursor_x + shift_x, cursor_y + shift_y), char, font=font, fill="black")
|
| transparent_draw.text((cursor_x + shift_x, cursor_y + shift_y), char, font=font, fill="black")
|
| cursor_y += fs * 0.6
|
| elif char in ('ー', '-', '—', '~'):
|
| lw = max(1, int(fs * 0.1))
|
| lx = cursor_x + fs * 0.5
|
| ly1_line = cursor_y + fs * 0.1
|
| ly2_line = ly1_line + fs * 0.7
|
| draw.line([(lx, ly1_line), (lx, ly2_line)], fill="black", width=lw)
|
| transparent_draw.line([(lx, ly1_line), (lx, ly2_line)], fill="black", width=lw)
|
| cursor_y += fs * 1.05
|
| elif char in ('っ', 'ゃ', 'ゅ', 'ょ', 'ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ'):
|
| draw.text((cursor_x + fs * 0.2, cursor_y), char, font=font, fill="black")
|
| transparent_draw.text((cursor_x + fs * 0.2, cursor_y), char, font=font, fill="black")
|
| cursor_y += fs * 1.05
|
| else:
|
| draw.text((cursor_x, cursor_y), char, font=font, fill="black")
|
| transparent_draw.text((cursor_x, cursor_y), char, font=font, fill="black")
|
| cursor_y += fs * 1.05
|
|
|
| extracted_texts.append(block_full_text)
|
|
|
|
|
| transparent_arr = np.array(transparent_pil)
|
| del transparent_pil
|
| return (frame_idx, transparent_arr, bboxes, img_bgr, erased_img_bgr, transparent_arr, extracted_texts)
|
|
|
|
|
|
|
| class WorkerThread(QThread):
|
| progress = pyqtSignal(str)
|
| finished = pyqtSignal(object, object, object, list, list)
|
| error = pyqtSignal(str)
|
|
|
| def __init__(self, image_path, draw_bubbles=True, font_size=22, bg_style=0, ignore_floating=True, extract_frames=False, skip_video=False, turbo=False):
|
| super().__init__()
|
| self.image_path = image_path
|
| self.draw_bubbles = draw_bubbles
|
| self.font_size = font_size
|
| self.bg_style = bg_style
|
| self.ignore_floating = ignore_floating
|
| self.extract_frames = extract_frames
|
| self.skip_video = skip_video
|
| self.turbo = turbo
|
|
|
| def gpu_detect(self, img_bgr, model):
|
| """Giai đoạn 1 (GPU): AI phát hiện text + tạo mask. Cực nhanh ~15-30ms."""
|
| mask, mask_refined, blk_list = model(img_bgr, refine_mode=REFINEMASK_INPAINT, keep_undetected_mask=True)
|
|
|
| if hasattr(mask_refined, 'numpy'):
|
| mask_refined = mask_refined.numpy()
|
| if mask_refined.max() == 1:
|
| mask_refined = (mask_refined * 255)
|
| mask_refined_uint8 = mask_refined.astype(np.uint8)
|
|
|
| return mask, mask_refined_uint8, blk_list
|
|
|
|
|
|
|
| def run(self):
|
| try:
|
| os.chdir(CTD_DIR)
|
| logger.info(f"WorkerThread START | file={os.path.basename(self.image_path)} | bg_style={self.bg_style} | RAM={get_memory_mb()}MB")
|
|
|
|
|
| import torch
|
| import cv2
|
| torch.set_num_threads(1)
|
| cv2.setNumThreads(1)
|
|
|
| if self.bg_style != 4:
|
| self.progress.emit("1. Đang nạp MangaOcr (Bỏ qua nếu chỉ cắt)...")
|
| from manga_ocr import MangaOcr
|
| manga_ocr = MangaOcr()
|
| else:
|
| manga_ocr = None
|
|
|
| self.progress.emit("2. Đang nạp Comic Text Detector (GPU + FP16 Turbo)...")
|
| model_path = os.path.join(CTD_DIR, 'data', 'comictextdetector.pt')
|
| device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
| model = TextDetector(model_path=model_path, input_size=1024, device=device, act='leaky')
|
|
|
|
|
| if device == 'cuda':
|
| try:
|
| model.net.half()
|
| model.half = True
|
| self.progress.emit(" ⚡ FP16 Half Precision: BẬT (GPU Turbo Mode)")
|
| except:
|
| self.progress.emit(" ⚠️ FP16 không khả dụng, dùng FP32 mặc định")
|
|
|
| is_video = self.image_path.lower().endswith(('.mp4', '.mkv', '.mov', '.avi'))
|
|
|
| if not is_video:
|
| self.progress.emit("3. Đang quét ảnh lấy tọa độ bong bóng...")
|
| img_bgr = cv2.imread(self.image_path)
|
| if img_bgr is None: raise Exception("Không thể đọc ảnh đầu vào!")
|
|
|
|
|
| mask, mask_refined_uint8, blk_list = self.gpu_detect(img_bgr, model)
|
| pblk_list = []
|
| for blk in blk_list:
|
| pblk_list.append({
|
| 'xyxy': blk.xyxy,
|
| 'lines': [np.array(pts).tolist() for pts in blk.lines]
|
| })
|
|
|
| init_worker_process(CTD_DIR, self.bg_style)
|
| task_args = (img_bgr, mask_refined_uint8, pblk_list, self.draw_bubbles, self.bg_style, self.font_size, self.ignore_floating, 1)
|
| res_tuple = process_frame_task(task_args)
|
| _, gbg_arr, bboxes, orig_bgr, erased_bgr, trans_arr, texts = res_tuple
|
|
|
| original_pil_img = Image.fromarray(cv2.cvtColor(orig_bgr, cv2.COLOR_BGR2RGB))
|
| result_img = Image.fromarray(cv2.cvtColor(erased_bgr, cv2.COLOR_BGR2RGB))
|
| trans_pil = Image.fromarray(trans_arr) if trans_arr is not None else None
|
|
|
| self.progress.emit(f"Hoàn thành! Đã xử lý {len(bboxes)} cụm chữ.")
|
| self.finished.emit(original_pil_img, result_img, trans_pil, texts, bboxes)
|
| return
|
|
|
|
|
|
|
|
|
| import queue
|
| import threading
|
| from concurrent.futures import ProcessPoolExecutor, wait, FIRST_COMPLETED, as_completed
|
| import multiprocessing
|
| max_cores = multiprocessing.cpu_count()
|
| CPU_WORKERS = max_cores if self.turbo else 4
|
|
|
| cap = cv2.VideoCapture(self.image_path)
|
| if not cap.isOpened(): raise Exception("Không thể đọc video đầu vào!")
|
|
|
| fps = cap.get(cv2.CAP_PROP_FPS)
|
| total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
|
| if not self.skip_video:
|
| out_path = self.image_path.rsplit('.', 1)[0] + '_transparent.mov'
|
| import subprocess
|
| import shutil
|
| ffmpeg_path = os.path.join(EXE_DIR, 'ffmpeg.exe')
|
| if not os.path.exists(ffmpeg_path):
|
| ffmpeg_path = shutil.which('ffmpeg') or 'ffmpeg'
|
|
|
| cmd = [
|
| ffmpeg_path, '-y',
|
| '-f', 'rawvideo',
|
| '-vcodec', 'rawvideo',
|
| '-s', f'{width}x{height}',
|
| '-pix_fmt', 'rgba',
|
| '-r', str(fps),
|
| '-i', '-',
|
| '-vcodec', 'qtrle',
|
| out_path
|
| ]
|
| process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
| else:
|
| out_path = None
|
| process = None
|
|
|
|
|
| if self.extract_frames:
|
| frames_dir = self.image_path.rsplit('.', 1)[0] + '_bubbles'
|
| import shutil
|
| if os.path.exists(frames_dir):
|
| try:
|
| shutil.rmtree(frames_dir)
|
| except:
|
| pass
|
| os.makedirs(frames_dir, exist_ok=True)
|
|
|
| def format_time(seconds):
|
| h = int(seconds // 3600)
|
| m = int((seconds % 3600) // 60)
|
| s = int(seconds % 60)
|
| ms = int((seconds - int(seconds)) * 1000)
|
| return f"{h}:{m:02d}:{s:02d}.{ms:03d}"
|
|
|
| def get_macro_boxes(boxes_in, merge_dist=80):
|
| if not boxes_in: return []
|
| boxes = [list(b) for b in boxes_in]
|
| merged = True
|
| while merged:
|
| merged = False
|
| for i in range(len(boxes)):
|
| for j in range(i+1, len(boxes)):
|
| b1, b2 = boxes[i], boxes[j]
|
| eb1 = [b1[0]-merge_dist, b1[1]-merge_dist, b1[2]+merge_dist, b1[3]+merge_dist]
|
| if max(eb1[0], b2[0]) < min(eb1[2], b2[2]) and max(eb1[1], b2[1]) < min(eb1[3], b2[3]):
|
| boxes[i] = [min(b1[0], b2[0]), min(b1[1], b2[1]), max(b1[2], b2[2]), max(b1[3], b2[3])]
|
| boxes.pop(j)
|
| merged = True
|
| break
|
| if merged: break
|
| return boxes
|
|
|
|
|
|
|
| gpu_to_cpu_queue = queue.Queue(maxsize=12)
|
| cpu_results_queue = queue.Queue(maxsize=20)
|
| gpu_done = threading.Event()
|
| stop_event = threading.Event()
|
|
|
|
|
|
|
|
|
| def gpu_producer():
|
| """GPU thread: Đọc video, lọc duplicate, chạy AI inference, đẩy vào queue."""
|
| frame_idx = 0
|
| last_unique_small = None
|
|
|
| try:
|
| while not stop_event.is_set():
|
| ret, frame_bgr = cap.read()
|
| if not ret:
|
| break
|
| frame_idx += 1
|
|
|
|
|
| if frame_idx % max(1, int(fps / 5)) != 0:
|
| continue
|
|
|
|
|
| small = cv2.resize(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY), (128, 128))
|
| if last_unique_small is not None:
|
| diff = cv2.absdiff(small, last_unique_small)
|
| _, diff_thresh = cv2.threshold(diff, 20, 255, cv2.THRESH_BINARY)
|
| if cv2.countNonZero(diff_thresh) <= 30:
|
| continue
|
|
|
| last_unique_small = small
|
|
|
|
|
| mask, mask_refined_uint8, blk_list = self.gpu_detect(frame_bgr, model)
|
|
|
| pblk_list = []
|
| for blk in blk_list:
|
| pblk_list.append({
|
| 'xyxy': blk.xyxy,
|
| 'lines': [np.array(pts).tolist() for pts in blk.lines]
|
| })
|
|
|
|
|
| gpu_to_cpu_queue.put((frame_idx, frame_bgr, mask_refined_uint8, pblk_list))
|
|
|
|
|
| if frame_idx % (max(1, int(fps / 5)) * 50) == 0:
|
| logger.debug(f"GPU producer frame={frame_idx} | RAM={get_memory_mb()}MB | gpu_q={gpu_to_cpu_queue.qsize()} | cpu_q={cpu_results_queue.qsize()}")
|
| except Exception as e:
|
| logger.error(f"GPU PRODUCER CRASH | frame={frame_idx} | RAM={get_memory_mb()}MB\n{traceback.format_exc()}")
|
| finally:
|
| cap.release()
|
| gpu_done.set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| use_multiprocessing = (self.bg_style != 4)
|
|
|
| def cpu_thread_worker():
|
| """CPU worker chạy bằng Thread (cho bg_style==4, không OCR)."""
|
|
|
| init_worker_process(CTD_DIR, self.bg_style)
|
| while not stop_event.is_set():
|
| try:
|
| item = gpu_to_cpu_queue.get(timeout=1.0)
|
| except queue.Empty:
|
| if gpu_done.is_set() and gpu_to_cpu_queue.empty():
|
| break
|
| continue
|
|
|
| frame_idx, frame_bgr, mask_refined_uint8, pblk_list = item
|
| task_args = (frame_bgr, mask_refined_uint8, pblk_list,
|
| self.draw_bubbles, self.bg_style, self.font_size,
|
| self.ignore_floating, frame_idx)
|
| try:
|
| res = process_frame_task(task_args)
|
| cpu_results_queue.put(res)
|
| except Exception as e:
|
| logger.error(f"CPU Thread CRASH | frame={frame_idx} | RAM={get_memory_mb()}MB\n{traceback.format_exc()}")
|
| gpu_to_cpu_queue.task_done()
|
|
|
| def cpu_dispatcher_multiprocessing():
|
| """Dispatcher dùng ProcessPool (cho bg_style!=4, có OCR)."""
|
| with ProcessPoolExecutor(max_workers=CPU_WORKERS, initializer=init_worker_process, initargs=(CTD_DIR, self.bg_style)) as executor:
|
| futures_map = {}
|
| while True:
|
| if len(futures_map) >= CPU_WORKERS * 2:
|
| done_fs, _ = wait(futures_map.keys(), return_when=FIRST_COMPLETED)
|
| for f in done_fs:
|
| try:
|
| res = f.result()
|
| cpu_results_queue.put(res)
|
| except Exception as e:
|
| print("Lỗi từ Multiprocessing:", e)
|
| futures_map.pop(f)
|
|
|
| try:
|
| item = gpu_to_cpu_queue.get(timeout=0.2)
|
| except queue.Empty:
|
| if gpu_done.is_set() and gpu_to_cpu_queue.empty():
|
| break
|
| done_list = [f for f in futures_map if f.done()]
|
| for f in done_list:
|
| try:
|
| res = f.result()
|
| cpu_results_queue.put(res)
|
| except Exception as e: pass
|
| futures_map.pop(f)
|
| continue
|
|
|
| frame_idx, frame_bgr, mask_refined_uint8, pblk_list = item
|
| task_args = (frame_bgr, mask_refined_uint8, pblk_list, self.draw_bubbles, self.bg_style, self.font_size, self.ignore_floating, frame_idx)
|
|
|
| fut = executor.submit(process_frame_task, task_args)
|
| futures_map[fut] = True
|
| gpu_to_cpu_queue.task_done()
|
|
|
| done_list = [f for f in futures_map if f.done()]
|
| for f in done_list:
|
| try:
|
| res = f.result()
|
| cpu_results_queue.put(res)
|
| except Exception as e: pass
|
| futures_map.pop(f)
|
|
|
| for f in as_completed(futures_map.keys()):
|
| try:
|
| res = f.result()
|
| cpu_results_queue.put(res)
|
| except: pass
|
|
|
|
|
| gpu_thread = threading.Thread(target=gpu_producer, daemon=True)
|
| gpu_thread.start()
|
|
|
| if use_multiprocessing:
|
| self.progress.emit(f"🚀 Pipeline MULTIPROCESSING (OCR + {CPU_WORKERS} Process)...")
|
| dispatcher_thread = threading.Thread(target=cpu_dispatcher_multiprocessing, daemon=True)
|
| dispatcher_thread.start()
|
| else:
|
| self.progress.emit(f"🚀 Pipeline THREAD trực tiếp ({CPU_WORKERS} Thread, không pickle)...")
|
| cpu_threads = []
|
| for _ in range(CPU_WORKERS):
|
| t = threading.Thread(target=cpu_thread_worker, daemon=True)
|
| t.start()
|
| cpu_threads.append(t)
|
| dispatcher_thread = None
|
|
|
|
|
| json_data = []
|
| last_saved_bgr = None
|
| results_collected = 0
|
|
|
| while True:
|
|
|
| all_done = gpu_done.is_set() and gpu_to_cpu_queue.empty()
|
|
|
| try:
|
| res_tuple = cpu_results_queue.get(timeout=0.5)
|
| idx, res_rgba, bboxes, orig_bgr, erased_bgr, trans_arr, texts = res_tuple
|
| except queue.Empty:
|
| if all_done and cpu_results_queue.empty():
|
| if dispatcher_thread is not None:
|
| if not dispatcher_thread.is_alive():
|
| break
|
| else:
|
| still_working = any(t.is_alive() for t in cpu_threads)
|
| if not still_working:
|
| break
|
| continue
|
| continue
|
|
|
| results_collected += 1
|
|
|
| macs = get_macro_boxes(bboxes)
|
|
|
|
|
| if self.extract_frames and len(macs) > 0:
|
| is_new_bubble = False
|
|
|
| if last_saved_bgr is None:
|
| is_new_bubble = True
|
| else:
|
| changed = False
|
| for m in macs:
|
| x1, y1, x2, y2 = max(0, int(m[0])), max(0, int(m[1])), min(width, int(m[2])), min(height, int(m[3]))
|
| if x2 - x1 >= 15 and y2 - y1 >= 15:
|
| c_crop = orig_bgr[y1:y2, x1:x2]
|
| c_gray = cv2.cvtColor(c_crop, cv2.COLOR_BGR2GRAY)
|
| l_gray = cv2.cvtColor(last_saved_bgr, cv2.COLOR_BGR2GRAY)
|
| res = cv2.matchTemplate(l_gray, c_gray, cv2.TM_CCOEFF_NORMED)
|
| _, max_val, _, _ = cv2.minMaxLoc(res)
|
| if max_val < 0.70:
|
| changed = True
|
| break
|
| if changed:
|
| is_new_bubble = True
|
|
|
| if is_new_bubble:
|
| out_bgra = cv2.cvtColor(res_rgba, cv2.COLOR_RGBA2BGRA)
|
| filename = f"bubble_{idx:05d}.png"
|
| cv2.imencode('.png', out_bgra)[1].tofile(os.path.join(frames_dir, filename))
|
| last_saved_bgr = orig_bgr.copy()
|
|
|
| t_sec = idx / fps if fps > 0 else 0
|
| json_data.append({
|
| "time_formatted": format_time(t_sec),
|
| "image_file": filename
|
| })
|
| elif len(macs) == 0:
|
| last_saved_bgr = None
|
|
|
| if res_rgba is not None and not self.skip_video:
|
| process.stdin.write(res_rgba.tobytes())
|
|
|
| self.progress.emit(f"🔥 Pipeline GPU↔CPU: Đã xử lý {results_collected} frames (frame ~{idx}/{total_frames})...")
|
|
|
|
|
| stop_event.set()
|
| gpu_thread.join(timeout=10)
|
| if dispatcher_thread is not None:
|
| dispatcher_thread.join(timeout=10)
|
| else:
|
| for t in cpu_threads:
|
| t.join(timeout=10)
|
|
|
| if self.extract_frames and json_data:
|
| json_path = self.image_path.rsplit('.', 1)[0] + '_bubbles.json'
|
| with open(json_path, 'w', encoding='utf-8') as f:
|
| json.dump(json_data, f, ensure_ascii=False, indent=4)
|
|
|
| if not self.skip_video:
|
| process.stdin.close()
|
| process.wait()
|
|
|
| logger.info(f"WorkerThread DONE | {results_collected} frames | RAM={get_memory_mb()}MB")
|
| self.progress.emit(f"✅ Hoàn thành! Pipeline GPU↔CPU đã xử lý {results_collected} frames.")
|
| self.finished.emit(None, None, None, [], [])
|
|
|
| except Exception as e:
|
| import traceback
|
| logger.error(f"WorkerThread CRASH | RAM={get_memory_mb()}MB\n{traceback.format_exc()}")
|
| self.error.emit(f"Lỗi hệ thống: {e}\n{traceback.format_exc()}")
|
| finally:
|
| os.chdir(EXE_DIR)
|
|
|
|
|
|
|
| class SceneDetectionWorker(QThread):
|
| progress = pyqtSignal(str)
|
| finished_sig = pyqtSignal()
|
|
|
| def __init__(self, video_path):
|
| super().__init__()
|
| self.video_path = video_path.replace("\\", "/")
|
|
|
| def run(self):
|
| dir_name = os.path.dirname(self.video_path)
|
| base_name = os.path.splitext(os.path.basename(self.video_path))[0]
|
| json_path = os.path.join(dir_name, f"{base_name}_scenes.json")
|
| output_dir = os.path.join(dir_name, f"{base_name}_frames").replace("\\", "/")
|
|
|
| if os.path.exists(json_path) and os.path.exists(output_dir):
|
| self.progress.emit(f"[Scene] Dữ liệu scene '{base_name}' đã tồn tại, bỏ qua...")
|
| self.finished_sig.emit()
|
| return
|
|
|
| os.makedirs(output_dir, exist_ok=True)
|
| self.progress.emit(f"[Scene] Đang tách scene: {base_name}...")
|
|
|
| cap = cv2.VideoCapture(self.video_path)
|
| if not cap.isOpened():
|
| self.progress.emit(f"[Scene] Lỗi: Không thể mở {self.video_path}")
|
| self.finished_sig.emit()
|
| return
|
|
|
| try:
|
| self._run_scene_detection(cap)
|
| except Exception as e:
|
| logger.error(f"SceneDetection CRASH | RAM={get_memory_mb()}MB\n{traceback.format_exc()}")
|
| finally:
|
| cap.release()
|
| self.finished_sig.emit()
|
|
|
| def _run_scene_detection(self, cap):
|
| """Logic tách scene, tách ra để wrap trong try/finally."""
|
| dir_name = os.path.dirname(self.video_path)
|
| base_name = os.path.splitext(os.path.basename(self.video_path))[0]
|
| json_path = os.path.join(dir_name, f"{base_name}_scenes.json")
|
| output_dir = os.path.join(dir_name, f"{base_name}_frames").replace("\\", "/")
|
|
|
| fps = cap.get(cv2.CAP_PROP_FPS)
|
| total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| if total_frames <= 0 or fps <= 0:
|
| return
|
|
|
|
|
| scan_fps = 5
|
| frame_step = max(1, int(fps / scan_fps))
|
| window_frames = max(3, int((fps * 0.4) / frame_step))
|
| hist_buffer = deque(maxlen=window_frames)
|
|
|
| THRESHOLD_DISTANCE = 0.25
|
| STABLE_THRESHOLD = 0.08
|
| COOLDOWN_SECONDS = 2.0
|
|
|
| is_transitioning = False
|
| transition_start_time = 0
|
| last_detect_time = -COOLDOWN_SECONDS
|
| frame_count = 0
|
| scene_changes_data = []
|
| pending_saves = []
|
| last_valid_frame = None
|
| last_emit_time = 0
|
|
|
| while True:
|
| ret, frame = cap.read()
|
| if not ret:
|
| for p in pending_saves:
|
| _, scene_info, image_path = p
|
| if last_valid_frame is not None:
|
| cv2.imwrite(image_path, last_valid_frame)
|
| scene_changes_data.append(scene_info)
|
| break
|
| last_valid_frame = frame
|
|
|
| current_time_sec = frame_count / fps
|
| frame_count += 1
|
|
|
| if frame_count == 1:
|
| scene_index = 1
|
| image_filename = f"scene_{scene_index:03d}.jpg"
|
| scene_info = {
|
| "scene_index": scene_index,
|
| "timestamp_seconds": 0.0,
|
| "time_formatted": "0:00:00.00",
|
| "image_filename": image_filename
|
| }
|
| pending_saves.append((1.0, scene_info, os.path.join(output_dir, image_filename)))
|
|
|
| for p in pending_saves[:]:
|
| target_time, scene_info, image_path = p
|
| if current_time_sec >= target_time:
|
| cv2.imwrite(image_path, frame)
|
| scene_changes_data.append(scene_info)
|
| pending_saves.remove(p)
|
|
|
| if current_time_sec - last_emit_time >= 2.0:
|
| self.progress.emit(f"[Scene] Đang quét frame {frame_count}/{total_frames}...")
|
| last_emit_time = current_time_sec
|
|
|
| if frame_count % frame_step != 0:
|
| continue
|
|
|
| new_width = 480
|
| new_height = int((new_width / frame.shape[1]) * frame.shape[0])
|
| small_frame = cv2.resize(frame, (new_width, new_height))
|
| hsv = cv2.cvtColor(small_frame, cv2.COLOR_BGR2HSV)
|
| hist = cv2.calcHist([hsv], [0, 1], None, [32, 32], [0, 180, 0, 256])
|
| cv2.normalize(hist, hist, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX)
|
|
|
| if len(hist_buffer) == window_frames:
|
| old_hist = hist_buffer[0]
|
| distance = cv2.compareHist(old_hist, hist, cv2.HISTCMP_BHATTACHARYYA)
|
| if not is_transitioning:
|
| if distance > THRESHOLD_DISTANCE and (current_time_sec - last_detect_time) > COOLDOWN_SECONDS:
|
| is_transitioning = True
|
| transition_start_time = current_time_sec
|
| else:
|
| time_in_fade = current_time_sec - transition_start_time
|
| if distance < STABLE_THRESHOLD or time_in_fade > 1.5:
|
| time_str = str(timedelta(seconds=current_time_sec))[:-3]
|
| scene_index = len(scene_changes_data) + len(pending_saves) + 1
|
| image_filename = f"scene_{scene_index:03d}.jpg"
|
| image_path = os.path.join(output_dir, image_filename)
|
| scene_info = {
|
| "scene_index": scene_index,
|
| "timestamp_seconds": round(current_time_sec, 2),
|
| "time_formatted": time_str,
|
| "image_filename": image_filename
|
| }
|
| pending_saves.append((current_time_sec + 1.0, scene_info, image_path))
|
| is_transitioning = False
|
| last_detect_time = current_time_sec + 1.0
|
| hist_buffer.clear()
|
| hist_buffer.append(hist)
|
|
|
| if scene_changes_data:
|
| with open(json_path, 'w', encoding='utf-8') as f:
|
| json.dump(scene_changes_data, f, indent=4, ensure_ascii=False)
|
| self.progress.emit(f"[Scene] Hoàn tất! Tìm thấy {len(scene_changes_data)} cảnh.")
|
|
|
|
|
|
|
| class BubbleEraserCanvas(QWidget):
|
| """Canvas vẽ cho phép dùng chuột tẩy xoá pixel thành trong suốt."""
|
| def __init__(self, parent=None):
|
| super().__init__(parent)
|
| self.pil_image = None
|
| self.display_pixmap = None
|
| self.brush_size = 30
|
| self.zoom = 1.0
|
| self.offset = QPoint(0, 0)
|
| self.is_drawing = False
|
| self.is_panning = False
|
| self.last_pan_pos = QPoint()
|
| self.last_draw_pos = None
|
| self.modified = False
|
| self._checker_bg = None
|
| self._checker_size = None
|
| self.setMouseTracking(True)
|
| self.setMinimumSize(400, 400)
|
| self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
|
| def load_image(self, pil_img):
|
| self.pil_image = pil_img.copy().convert('RGBA')
|
| self.modified = False
|
| self.zoom = 1.0
|
| self.offset = QPoint(0, 0)
|
| self._fit_zoom()
|
| self._update_pixmap()
|
| self.update()
|
|
|
| def _fit_zoom(self):
|
| if self.pil_image is None:
|
| return
|
| iw, ih = self.pil_image.size
|
| cw, ch = self.width(), self.height()
|
| if iw > 0 and ih > 0:
|
| self.zoom = min(cw / iw, ch / ih, 1.0) * 0.95
|
|
|
| self.offset = QPoint(
|
| int((cw - iw * self.zoom) / 2),
|
| int((ch - ih * self.zoom) / 2)
|
| )
|
|
|
| def _update_pixmap(self):
|
| if self.pil_image is None:
|
| self.display_pixmap = None
|
| return
|
| data = self.pil_image.tobytes('raw', 'RGBA')
|
| qimg = QImage(data, self.pil_image.width, self.pil_image.height, QImage.Format_RGBA8888)
|
| self.display_pixmap = QPixmap.fromImage(qimg)
|
|
|
| def _screen_to_image(self, pos):
|
| x = (pos.x() - self.offset.x()) / self.zoom
|
| y = (pos.y() - self.offset.y()) / self.zoom
|
| return int(x), int(y)
|
|
|
| def _erase_at(self, img_x, img_y):
|
| if self.pil_image is None:
|
| return
|
| draw = ImageDraw.Draw(self.pil_image)
|
| r = int(self.brush_size / 2 / self.zoom)
|
| r = max(r, 1)
|
| draw.ellipse([img_x - r, img_y - r, img_x + r, img_y + r], fill=(0, 0, 0, 0))
|
| self.modified = True
|
|
|
| def _magic_wand_erase(self, img_x, img_y):
|
| """Tẩy Đũa Thần: Xoá toàn bộ mảng màu giống nhau bao quanh vị trí click."""
|
| if self.pil_image is None:
|
| return
|
|
|
| cv_img = np.array(self.pil_image)
|
| h, w = cv_img.shape[:2]
|
|
|
| if not (0 <= img_x < w and 0 <= img_y < h):
|
| return
|
|
|
|
|
| rgb_img = cv_img[:, :, :3].copy()
|
|
|
|
|
| mask = np.zeros((h + 2, w + 2), np.uint8)
|
| seed_pt = (img_x, img_y)
|
|
|
|
|
| tol = (30, 30, 30)
|
|
|
|
|
| flags = 4 | (255 << 8) | cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY
|
| cv2.floodFill(rgb_img, mask, seed_pt, (0, 0, 0), tol, tol, flags)
|
|
|
|
|
| fill_mask = mask[1:-1, 1:-1]
|
|
|
|
|
| cv_img[fill_mask == 255] = [0, 0, 0, 0]
|
|
|
| self.pil_image = Image.fromarray(cv_img, 'RGBA')
|
| self.modified = True
|
|
|
| def _erase_line(self, x1, y1, x2, y2):
|
| """Vẽ liên tục giữa 2 điểm để không bị đứt nét khi kéo nhanh."""
|
| if self.pil_image is None:
|
| return
|
| draw = ImageDraw.Draw(self.pil_image)
|
| r = int(self.brush_size / 2 / self.zoom)
|
| r = max(r, 1)
|
|
|
| dx = abs(x2 - x1)
|
| dy = abs(y2 - y1)
|
| steps = max(dx, dy, 1)
|
| for i in range(steps + 1):
|
| t = i / steps
|
| px = int(x1 + (x2 - x1) * t)
|
| py = int(y1 + (y2 - y1) * t)
|
| draw.ellipse([px - r, py - r, px + r, py + r], fill=(0, 0, 0, 0))
|
| self.modified = True
|
|
|
| def _ensure_checker_bg(self):
|
| """FIX: Cache nền caro thành QPixmap tĩnh, không vẽ lại mỗi frame."""
|
| current_size = (self.width(), self.height())
|
| if self._checker_bg is not None and self._checker_size == current_size:
|
| return
|
| self._checker_size = current_size
|
| self._checker_bg = QPixmap(self.width(), self.height())
|
| p = QPainter(self._checker_bg)
|
| tile = 16
|
| for cy in range(0, self.height(), tile):
|
| for cx in range(0, self.width(), tile):
|
| color = QColor(200, 200, 200) if (cx // tile + cy // tile) % 2 == 0 else QColor(255, 255, 255)
|
| p.fillRect(cx, cy, tile, tile, color)
|
| p.end()
|
|
|
| def paintEvent(self, event):
|
| painter = QPainter(self)
|
| painter.setRenderHint(QPainter.SmoothPixmapTransform)
|
|
|
|
|
| self._ensure_checker_bg()
|
| painter.drawPixmap(0, 0, self._checker_bg)
|
|
|
| if self.display_pixmap:
|
| painter.translate(self.offset)
|
| painter.scale(self.zoom, self.zoom)
|
| painter.drawPixmap(0, 0, self.display_pixmap)
|
| painter.resetTransform()
|
|
|
|
|
| cursor_pos = self.mapFromGlobal(QCursor.pos())
|
| if self.rect().contains(cursor_pos):
|
| painter.setPen(QPen(QColor(255, 0, 0, 180), 2, Qt.DashLine))
|
| painter.setBrush(QBrush(QColor(255, 0, 0, 40)))
|
| painter.drawEllipse(cursor_pos, self.brush_size // 2, self.brush_size // 2)
|
|
|
| def mousePressEvent(self, event):
|
| if event.button() == Qt.LeftButton:
|
| ix, iy = self._screen_to_image(event.pos())
|
| if event.modifiers() & Qt.ShiftModifier:
|
|
|
| self._magic_wand_erase(ix, iy)
|
| self._update_pixmap()
|
| self.update()
|
| else:
|
| self.is_drawing = True
|
| self._erase_at(ix, iy)
|
| self.last_draw_pos = (ix, iy)
|
| self._update_pixmap()
|
| self.update()
|
| elif event.button() == Qt.MiddleButton or event.button() == Qt.RightButton:
|
| self.is_panning = True
|
| self.last_pan_pos = event.pos()
|
|
|
| def mouseMoveEvent(self, event):
|
| if self.is_drawing:
|
| ix, iy = self._screen_to_image(event.pos())
|
| if self.last_draw_pos:
|
| self._erase_line(self.last_draw_pos[0], self.last_draw_pos[1], ix, iy)
|
| else:
|
| self._erase_at(ix, iy)
|
| self.last_draw_pos = (ix, iy)
|
| self._update_pixmap()
|
| self.update()
|
| elif self.is_panning:
|
| delta = event.pos() - self.last_pan_pos
|
| self.offset += delta
|
| self.last_pan_pos = event.pos()
|
| self.update()
|
| else:
|
| self.update()
|
|
|
| def mouseReleaseEvent(self, event):
|
| if event.button() == Qt.LeftButton:
|
| self.is_drawing = False
|
| self.last_draw_pos = None
|
| elif event.button() == Qt.MiddleButton or event.button() == Qt.RightButton:
|
| self.is_panning = False
|
|
|
| def wheelEvent(self, event):
|
| old_zoom = self.zoom
|
| factor = 1.15
|
| if event.angleDelta().y() > 0:
|
| self.zoom *= factor
|
| else:
|
| self.zoom /= factor
|
| self.zoom = max(0.1, min(self.zoom, 10.0))
|
|
|
|
|
| cursor = event.pos()
|
| self.offset = QPoint(
|
| int(cursor.x() - (cursor.x() - self.offset.x()) * (self.zoom / old_zoom)),
|
| int(cursor.y() - (cursor.y() - self.offset.y()) * (self.zoom / old_zoom))
|
| )
|
| self.update()
|
|
|
|
|
| class ThumbnailLoaderThread(QThread):
|
| """Background thread tải thumbnail siêu nhanh bằng NumPy."""
|
| thumb_ready = pyqtSignal(str, str, object)
|
| progress_sig = pyqtSignal(int, int)
|
| done_sig = pyqtSignal(int)
|
|
|
| def __init__(self, folder):
|
| super().__init__()
|
| self.folder = folder
|
|
|
| def run(self):
|
| files = sorted([f for f in os.listdir(self.folder) if f.lower().endswith('.png')])
|
| total = len(files)
|
| for i, fname in enumerate(files):
|
| fpath = os.path.join(self.folder, fname)
|
| try:
|
| pil = Image.open(fpath).convert('RGBA')
|
|
|
| w, h = pil.size
|
| arr = np.array(pil)
|
| bg = np.zeros((h, w, 3), dtype=np.uint8)
|
| tile = 8
|
| yy, xx = np.mgrid[0:h, 0:w]
|
| checker = ((xx // tile) + (yy // tile)) % 2 == 0
|
| bg[checker] = [255, 255, 255]
|
| bg[~checker] = [220, 220, 220]
|
|
|
| alpha = arr[:, :, 3:4].astype(np.float32) / 255.0
|
| fg = arr[:, :, :3].astype(np.float32)
|
| blended = (fg * alpha + bg.astype(np.float32) * (1 - alpha)).astype(np.uint8)
|
| result = Image.fromarray(blended, 'RGB')
|
| result.thumbnail((200, 120))
|
| data = result.tobytes('raw', 'RGB')
|
| qimg = QImage(data, result.width, result.height, result.width * 3, QImage.Format_RGB888)
|
| pixmap = QPixmap.fromImage(qimg)
|
| self.thumb_ready.emit(fname, fpath, pixmap)
|
| except:
|
| self.thumb_ready.emit(fname, fpath, None)
|
| self.progress_sig.emit(i + 1, total)
|
| self.done_sig.emit(total)
|
|
|
|
|
| class BubbleEditorWindow(QMainWindow):
|
| """Cửa sổ duyệt và tẩy xoá bong bóng."""
|
| def __init__(self, parent=None):
|
| super().__init__(parent)
|
| self.setWindowTitle("🧹 Bubble Eraser - Tẩy Xoá Bong Bóng")
|
| self.resize(1200, 700)
|
| self.current_folder = ""
|
| self.current_file = ""
|
| self.bubble_files = []
|
| self._thumb_loader = None
|
| self.video_path = ""
|
| self.frame_offset = 0
|
|
|
| central = QWidget()
|
| self.setCentralWidget(central)
|
| main_layout = QHBoxLayout(central)
|
|
|
|
|
| left_panel = QWidget()
|
| left_layout = QVBoxLayout(left_panel)
|
| left_panel.setFixedWidth(280)
|
|
|
| self.btn_open_folder = QPushButton("📂 Chọn Thư Mục Bubbles")
|
| self.btn_open_folder.setMinimumHeight(35)
|
| self.btn_open_folder.clicked.connect(self.open_folder)
|
| left_layout.addWidget(self.btn_open_folder)
|
|
|
| self.lbl_folder = QLabel("Chưa chọn thư mục")
|
| self.lbl_folder.setWordWrap(True)
|
| left_layout.addWidget(self.lbl_folder)
|
|
|
|
|
| self.thumb_list = QListWidget()
|
| self.thumb_list.setIconSize(QSize(240, 135))
|
| self.thumb_list.setSpacing(4)
|
| self.thumb_list.setWordWrap(True)
|
| self.thumb_list.currentItemChanged.connect(self.on_thumb_selected)
|
| left_layout.addWidget(self.thumb_list)
|
|
|
|
|
| self.btn_delete = QPushButton("🗑️ Xoá File Đang Chọn")
|
| self.btn_delete.setMinimumHeight(30)
|
| self.btn_delete.setStyleSheet("background-color: #E74C3C; color: white; font-weight: bold;")
|
| self.btn_delete.clicked.connect(self.delete_selected)
|
| left_layout.addWidget(self.btn_delete)
|
|
|
|
|
| right_panel = QWidget()
|
| right_layout = QVBoxLayout(right_panel)
|
|
|
|
|
| toolbar = QHBoxLayout()
|
| toolbar.addWidget(QLabel("Kích thước cọ tẩy:"))
|
| self.brush_slider = QSlider(Qt.Horizontal)
|
| self.brush_slider.setRange(5, 200)
|
| self.brush_slider.setValue(30)
|
| self.brush_slider.valueChanged.connect(self.on_brush_changed)
|
| toolbar.addWidget(self.brush_slider)
|
| self.lbl_brush = QLabel("30px")
|
| toolbar.addWidget(self.lbl_brush)
|
|
|
| self.btn_save = QPushButton("💾 Lưu (Ctrl+S)")
|
| self.btn_save.setMinimumHeight(35)
|
| self.btn_save.setStyleSheet("background-color: #2ECC71; color: white; font-weight: bold;")
|
| self.btn_save.clicked.connect(self.save_current)
|
| toolbar.addWidget(self.btn_save)
|
|
|
| self.btn_undo = QPushButton("↩️ Hoàn tác")
|
| self.btn_undo.setMinimumHeight(35)
|
| self.btn_undo.clicked.connect(self.undo_changes)
|
| toolbar.addWidget(self.btn_undo)
|
|
|
| right_layout.addLayout(toolbar)
|
|
|
|
|
| recapture_bar = QHBoxLayout()
|
| self.btn_prev_frame = QPushButton("◀ Lùi Frame")
|
| self.btn_prev_frame.setMinimumHeight(32)
|
| self.btn_prev_frame.setStyleSheet("background-color: #3498DB; color: white; font-weight: bold;")
|
| self.btn_prev_frame.clicked.connect(lambda: self.recapture_bubble(-5))
|
| recapture_bar.addWidget(self.btn_prev_frame)
|
|
|
| self.lbl_frame_offset = QLabel("Frame gốc")
|
| self.lbl_frame_offset.setAlignment(Qt.AlignCenter)
|
| self.lbl_frame_offset.setStyleSheet("font-weight: bold; font-size: 12px;")
|
| recapture_bar.addWidget(self.lbl_frame_offset)
|
|
|
| self.btn_next_frame = QPushButton("Tiến Frame ▶")
|
| self.btn_next_frame.setMinimumHeight(32)
|
| self.btn_next_frame.setStyleSheet("background-color: #3498DB; color: white; font-weight: bold;")
|
| self.btn_next_frame.clicked.connect(lambda: self.recapture_bubble(+5))
|
| recapture_bar.addWidget(self.btn_next_frame)
|
|
|
| right_layout.addLayout(recapture_bar)
|
|
|
|
|
| self.canvas = BubbleEraserCanvas()
|
| right_layout.addWidget(self.canvas)
|
|
|
| self.lbl_status_editor = QLabel("Double-click ảnh trái để mở | Trái = Tẩy Cọ | Shift+Trái = Đũa Thần | Cuộn = Zoom")
|
| self.lbl_status_editor.setStyleSheet("color: gray; padding: 4px;")
|
| right_layout.addWidget(self.lbl_status_editor)
|
|
|
| main_layout.addWidget(left_panel)
|
| main_layout.addWidget(right_panel, 1)
|
|
|
|
|
| QShortcut(QKeySequence("Ctrl+S"), self, self.save_current)
|
| QShortcut(QKeySequence("Ctrl+Z"), self, self.undo_changes)
|
| QShortcut(QKeySequence("Left"), self, lambda: self.recapture_bubble(-5))
|
| QShortcut(QKeySequence("Right"), self, lambda: self.recapture_bubble(+5))
|
|
|
| self.original_pil = None
|
|
|
| def _get_frame_index(self, filepath):
|
| """Trích xuất frame index từ tên file bubble_XXXXX.png."""
|
| import re
|
| basename = os.path.basename(filepath)
|
| match = re.search(r'bubble_(\d+)', basename)
|
| return int(match.group(1)) if match else None
|
|
|
| def _ensure_video_path(self):
|
| """Đảm bảo đã chọn video gốc (chỉ hỏi 1 lần)."""
|
| if self.video_path and os.path.exists(self.video_path):
|
| return True
|
| video_path, _ = QFileDialog.getOpenFileName(
|
| self, "Chọn Video Gốc (để chụp lại frame)",
|
| os.path.dirname(self.current_folder) if self.current_folder else "",
|
| "Video (*.mp4 *.mkv *.mov *.avi);;All files (*)")
|
| if video_path and os.path.exists(video_path):
|
| self.video_path = video_path
|
| return True
|
| return False
|
|
|
| def _set_bubble_nav_enabled(self, enabled):
|
| self.btn_prev_frame.setEnabled(enabled)
|
| self.btn_next_frame.setEnabled(enabled)
|
|
|
| def recapture_bubble(self, direction):
|
| """Chụp lại bubble từ frame lân cận (chạy nền, không đơ UI)."""
|
| if not self.current_file:
|
| QMessageBox.information(self, "Thông báo", "Hãy chọn một ảnh bubble trước!")
|
| return
|
|
|
| frame_idx = self._get_frame_index(self.current_file)
|
| if frame_idx is None:
|
| QMessageBox.warning(self, "Lỗi", "Không thể đọc chỉ số frame từ tên file!")
|
| return
|
|
|
| if not self._ensure_video_path():
|
| return
|
|
|
| self.frame_offset += direction
|
| target_frame = frame_idx + self.frame_offset
|
|
|
| if target_frame < 0:
|
| self.frame_offset -= direction
|
| QMessageBox.warning(self, "Lỗi", "Đã về frame đầu tiên!")
|
| return
|
|
|
|
|
| self._set_bubble_nav_enabled(False)
|
| self.lbl_status_editor.setText(f"⏳ Đang chụp frame #{target_frame}...")
|
|
|
| self._seek_worker = FrameSeekWorker(
|
| self.video_path, target_frame, save_path=None,
|
| extra_data={'direction': direction, 'target_frame': target_frame}
|
| )
|
| self._seek_worker.done.connect(self._on_bubble_frame_done)
|
| self._seek_worker.error.connect(self._on_bubble_frame_error)
|
| self._seek_worker.start()
|
|
|
| def _on_bubble_frame_done(self, result):
|
| """Callback: frame đã đọc xong, giờ ghép alpha mask trên main thread."""
|
| self._set_bubble_nav_enabled(True)
|
| extra = result['extra']
|
| target_frame = extra['target_frame']
|
| new_frame_bgr = result['frame']
|
|
|
| current_pil = self.original_pil if self.original_pil else self.canvas.pil_image
|
| if current_pil is None:
|
| self.frame_offset -= extra['direction']
|
| return
|
|
|
| current_arr = np.array(current_pil.convert('RGBA'))
|
| alpha_mask = current_arr[:, :, 3]
|
| bh, bw = current_arr.shape[:2]
|
|
|
| rgb_template = current_arr[:, :, :3]
|
| rgb_template_bgr = cv2.cvtColor(rgb_template, cv2.COLOR_RGB2BGR)
|
| match_mask = (alpha_mask > 128).astype(np.uint8) * 255
|
|
|
| fh, fw = new_frame_bgr.shape[:2]
|
|
|
| if bh < fh and bw < fw:
|
| res = cv2.matchTemplate(new_frame_bgr, rgb_template_bgr, cv2.TM_CCOEFF_NORMED, mask=match_mask)
|
| _, _, _, max_loc = cv2.minMaxLoc(res)
|
| x_off, y_off = max_loc
|
| else:
|
| x_off, y_off = 0, 0
|
|
|
| y1, y2 = max(0, y_off), min(fh, y_off + bh)
|
| x1, x2 = max(0, x_off), min(fw, x_off + bw)
|
|
|
| crop_bgr = new_frame_bgr[y1:y2, x1:x2]
|
| crop_rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
|
|
|
| new_rgba = np.zeros((bh, bw, 4), dtype=np.uint8)
|
| ch, cw = crop_rgb.shape[:2]
|
| new_rgba[:ch, :cw, :3] = crop_rgb[:min(ch,bh), :min(cw,bw)]
|
| new_rgba[:, :, 3] = alpha_mask
|
|
|
| new_pil = Image.fromarray(new_rgba, 'RGBA')
|
| self.canvas.load_image(new_pil)
|
| self.canvas.modified = True
|
|
|
| offset_str = f"{'+'if self.frame_offset>=0 else ''}{self.frame_offset}" if self.frame_offset != 0 else "gốc"
|
| self.lbl_frame_offset.setText(f"Frame {offset_str} (#{target_frame})")
|
| self.lbl_status_editor.setText(f"📸 Đã chụp lại từ frame #{target_frame} | Ctrl+S để lưu | ◀▶ để dịch tiếp")
|
|
|
| def _on_bubble_frame_error(self, msg):
|
| self._set_bubble_nav_enabled(True)
|
| self.frame_offset -= 1
|
| QMessageBox.warning(self, "Lỗi", msg)
|
|
|
| def open_folder(self):
|
| folder = QFileDialog.getExistingDirectory(self, "Chọn thư mục Bubbles")
|
| if folder:
|
| self.current_folder = folder
|
| self.lbl_folder.setText(folder)
|
| self.load_thumbnails()
|
|
|
| def load_thumbnails(self):
|
| self.thumb_list.clear()
|
| self.bubble_files = []
|
| if not self.current_folder:
|
| return
|
| self.btn_open_folder.setEnabled(False)
|
| self.lbl_status_editor.setText("⏳ Đang tải thumbnail...")
|
|
|
| self._thumb_loader = ThumbnailLoaderThread(self.current_folder)
|
| self._thumb_loader.thumb_ready.connect(self._on_thumb_ready)
|
| self._thumb_loader.progress_sig.connect(self._on_thumb_progress)
|
| self._thumb_loader.done_sig.connect(self._on_thumb_done)
|
| self._thumb_loader.start()
|
|
|
| def _on_thumb_ready(self, fname, fpath, pixmap):
|
| self.bubble_files.append(fpath)
|
| item = QListWidgetItem()
|
| if pixmap:
|
| from PyQt5.QtGui import QIcon
|
| item.setIcon(QIcon(pixmap))
|
| item.setText(fname)
|
| item.setData(Qt.UserRole, fpath)
|
| self.thumb_list.addItem(item)
|
|
|
| def _on_thumb_progress(self, current, total):
|
| self.lbl_status_editor.setText(f"⏳ Đang tải thumbnail... {current}/{total}")
|
|
|
| def _on_thumb_done(self, total):
|
| self.btn_open_folder.setEnabled(True)
|
| self.lbl_status_editor.setText(f"✅ Đã tải {total} ảnh bubble. Double-click để mở chỉnh sửa.")
|
|
|
| def on_thumb_selected(self, current, previous):
|
|
|
| if self.canvas.modified and self.current_file:
|
| self._silent_save()
|
| if current:
|
| fpath = current.data(Qt.UserRole)
|
| self.open_bubble(fpath)
|
|
|
| def open_bubble(self, fpath):
|
| try:
|
| pil_img = Image.open(fpath).convert('RGBA')
|
| self.current_file = fpath
|
| self.original_pil = pil_img.copy()
|
| self.frame_offset = 0
|
| self.lbl_frame_offset.setText("Frame gốc")
|
| self.canvas.load_image(pil_img)
|
| frame_idx = self._get_frame_index(fpath)
|
| frame_str = f" (frame #{frame_idx})" if frame_idx else ""
|
| self.lbl_status_editor.setText(f"Đang sửa: {os.path.basename(fpath)}{frame_str} | ◀▶ chụp lại frame | Ctrl+S lưu")
|
| except Exception as e:
|
| QMessageBox.warning(self, "Lỗi", f"Không thể mở ảnh: {e}")
|
|
|
| def on_brush_changed(self, val):
|
| self.canvas.brush_size = val
|
| self.lbl_brush.setText(f"{val}px")
|
| self.canvas.update()
|
|
|
| def _silent_save(self):
|
| """Lưu im lặng không reload thumbnail."""
|
| if not self.current_file or self.canvas.pil_image is None:
|
| return
|
| try:
|
| self.canvas.pil_image.save(self.current_file)
|
| self.canvas.modified = False
|
| self.original_pil = self.canvas.pil_image.copy()
|
|
|
| self._update_single_thumbnail(self.current_file)
|
| except:
|
| pass
|
|
|
| def _update_single_thumbnail(self, fpath):
|
| """Chỉ cập nhật 1 thumbnail duy nhất trong danh sách."""
|
| for i in range(self.thumb_list.count()):
|
| item = self.thumb_list.item(i)
|
| if item and item.data(Qt.UserRole) == fpath:
|
| try:
|
| pil = Image.open(fpath).convert('RGBA')
|
| w, h = pil.size
|
| arr = np.array(pil)
|
| bg = np.zeros((h, w, 3), dtype=np.uint8)
|
| tile = 8
|
| yy, xx = np.mgrid[0:h, 0:w]
|
| checker = ((xx // tile) + (yy // tile)) % 2 == 0
|
| bg[checker] = [255, 255, 255]
|
| bg[~checker] = [220, 220, 220]
|
| alpha = arr[:, :, 3:4].astype(np.float32) / 255.0
|
| fg = arr[:, :, :3].astype(np.float32)
|
| blended = (fg * alpha + bg.astype(np.float32) * (1 - alpha)).astype(np.uint8)
|
| result = Image.fromarray(blended, 'RGB')
|
| result.thumbnail((240, 135))
|
| data = result.tobytes('raw', 'RGB')
|
| qimg = QImage(data, result.width, result.height, result.width * 3, QImage.Format_RGB888)
|
| from PyQt5.QtGui import QIcon
|
| item.setIcon(QIcon(QPixmap.fromImage(qimg)))
|
| except:
|
| pass
|
| break
|
|
|
| def save_current(self):
|
| if not self.current_file or self.canvas.pil_image is None:
|
| return
|
| try:
|
| self.canvas.pil_image.save(self.current_file)
|
| self.canvas.modified = False
|
| self.original_pil = self.canvas.pil_image.copy()
|
| self.lbl_status_editor.setText(f"✅ Đã lưu: {os.path.basename(self.current_file)}")
|
| self._update_single_thumbnail(self.current_file)
|
| except Exception as e:
|
| QMessageBox.warning(self, "Lỗi lưu", str(e))
|
|
|
| def undo_changes(self):
|
| if self.original_pil and self.current_file:
|
| self.canvas.load_image(self.original_pil)
|
| self.lbl_status_editor.setText(f"↩️ Đã hoàn tác về bản gốc: {os.path.basename(self.current_file)}")
|
|
|
| def delete_selected(self):
|
| item = self.thumb_list.currentItem()
|
| if not item:
|
| return
|
| fpath = item.data(Qt.UserRole)
|
| reply = QMessageBox.question(self, "Xác nhận xoá",
|
| f"Bạn có chắc muốn xoá file:\n{os.path.basename(fpath)}?",
|
| QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
| if reply == QMessageBox.Yes:
|
| try:
|
| os.remove(fpath)
|
|
|
|
|
| row = self.thumb_list.row(item)
|
| self.thumb_list.takeItem(row)
|
| if fpath in self.bubble_files:
|
| self.bubble_files.remove(fpath)
|
|
|
| if self.current_file == fpath:
|
| self.current_file = ""
|
| self.canvas.pil_image = None
|
| self.canvas.display_pixmap = None
|
| self.canvas.update()
|
| self.lbl_status_editor.setText("✅ Đã xoá file. Hãy chọn ảnh khác.")
|
| except Exception as e:
|
| QMessageBox.warning(self, "Lỗi", f"Không thể xoá file: {e}")
|
|
|
|
|
|
|
| class FrameSeekWorker(QThread):
|
| """Thread nền: seek video + đọc frame + lưu ảnh."""
|
| done = pyqtSignal(object)
|
| error = pyqtSignal(str)
|
|
|
| def __init__(self, video_path, target_frame, save_path, extra_data=None):
|
| super().__init__()
|
| self.video_path = video_path
|
| self.target_frame = target_frame
|
| self.save_path = save_path
|
| self.extra_data = extra_data
|
|
|
| def run(self):
|
| try:
|
| cap = cv2.VideoCapture(self.video_path)
|
| total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| fps = cap.get(cv2.CAP_PROP_FPS)
|
|
|
| if self.target_frame < 0 or self.target_frame >= total:
|
| cap.release()
|
| self.error.emit("Đã tới giới hạn frame!")
|
| return
|
|
|
| cap.set(cv2.CAP_PROP_POS_FRAMES, self.target_frame)
|
| ret, frame = cap.read()
|
| cap.release()
|
|
|
| if not ret or frame is None:
|
| self.error.emit("Không đọc được frame từ video!")
|
| return
|
|
|
|
|
| if self.save_path:
|
| cv2.imwrite(self.save_path, frame)
|
|
|
| self.done.emit({
|
| 'frame': frame,
|
| 'save_path': self.save_path,
|
| 'target_frame': self.target_frame,
|
| 'fps': fps,
|
| 'extra': self.extra_data
|
| })
|
| except Exception as e:
|
| self.error.emit(str(e))
|
|
|
|
|
|
|
| class SceneImageWidget(QFrame):
|
| clicked = pyqtSignal(object)
|
|
|
| def __init__(self, scene_info, dir_path, parent=None):
|
| super().__init__(parent)
|
| self.scene_info = scene_info
|
| self.image_path = os.path.join(dir_path, scene_info.get("image_filename", ""))
|
| self._selected = False
|
| self.setFrameShape(QFrame.Box)
|
| self.setLineWidth(2)
|
| self.setStyleSheet("QFrame { border: 1px solid #ccc; background-color: transparent; border-radius: 5px; }")
|
|
|
| layout = QVBoxLayout(self)
|
| layout.setContentsMargins(5, 5, 5, 5)
|
| self.img_label = QLabel()
|
| self.img_label.setAlignment(Qt.AlignCenter)
|
| if os.path.exists(self.image_path):
|
| pixmap = QPixmap(self.image_path)
|
| self.img_label.setPixmap(pixmap.scaled(200, 150, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
| else:
|
| self.img_label.setText("Ảnh lỗi")
|
| info_label = QLabel(f"Cảnh {scene_info['scene_index']}\n[{scene_info['time_formatted']}]\n{scene_info['timestamp_seconds']}s")
|
| info_label.setAlignment(Qt.AlignCenter)
|
| info_label.setStyleSheet("font-weight: bold; font-size: 11px;")
|
| self.shift_label = QLabel("")
|
| self.shift_label.setAlignment(Qt.AlignCenter)
|
| self.shift_label.setStyleSheet("color: #3498DB; font-size: 9px;")
|
| self.shift_label.setWordWrap(True)
|
| layout.addWidget(self.img_label)
|
| layout.addWidget(info_label)
|
| layout.addWidget(self.shift_label)
|
|
|
| def set_selected(self, val):
|
| self._selected = val
|
| if val:
|
| self.setStyleSheet("QFrame { border: 3px solid #2b78e4; background-color: #e6f0ff; border-radius: 5px; }")
|
| else:
|
| self.setStyleSheet("QFrame { border: 1px solid #ccc; background-color: transparent; border-radius: 5px; }")
|
|
|
| def mousePressEvent(self, event):
|
| if event.button() == Qt.LeftButton:
|
| self.clicked.emit(self)
|
| super().mousePressEvent(event)
|
|
|
|
|
| class SceneReviewWindow(QMainWindow):
|
| def __init__(self, parent=None):
|
| super().__init__(parent)
|
| self.setWindowTitle("🖼️ Duyệt & Gộp Scene")
|
| self.resize(1400, 800)
|
| self.setFocusPolicy(Qt.StrongFocus)
|
| self.MAX_COLS = 5
|
| self.current_folder = ""
|
| self.current_json = ""
|
| self.current_scene_data = []
|
| self.selected_widgets = []
|
| self.all_widgets = []
|
| self.last_clicked_widget = None
|
| self.video_path = ""
|
|
|
| central = QWidget()
|
| self.setCentralWidget(central)
|
| main_layout = QVBoxLayout(central)
|
|
|
|
|
| top = QHBoxLayout()
|
| self.btn_open = QPushButton("📂 Chọn Thư Mục Frames")
|
| self.btn_open.setMinimumHeight(35)
|
| self.btn_open.clicked.connect(self.open_folder)
|
| top.addWidget(self.btn_open)
|
| self.lbl_project = QLabel("Chưa chọn")
|
| self.lbl_project.setStyleSheet("font-weight: bold; font-size: 14px;")
|
| top.addWidget(self.lbl_project, 1)
|
|
|
| self.btn_prev_scene = QPushButton("◀ Lùi Frame")
|
| self.btn_prev_scene.setMinimumHeight(32)
|
| self.btn_prev_scene.setStyleSheet("background-color: #3498DB; color: white; font-weight: bold;")
|
| self.btn_prev_scene.clicked.connect(lambda: self._shift_scene_frame(-5))
|
| top.addWidget(self.btn_prev_scene)
|
|
|
| self.btn_next_scene = QPushButton("Tiến Frame ▶")
|
| self.btn_next_scene.setMinimumHeight(32)
|
| self.btn_next_scene.setStyleSheet("background-color: #3498DB; color: white; font-weight: bold;")
|
| self.btn_next_scene.clicked.connect(lambda: self._shift_scene_frame(+5))
|
| top.addWidget(self.btn_next_scene)
|
|
|
| lbl_hint = QLabel("Shift/Ctrl chọn nhiều | Space = Gộp | Delete = Xoá")
|
| lbl_hint.setStyleSheet("color: red; font-style: italic;")
|
| top.addWidget(lbl_hint)
|
| main_layout.addLayout(top)
|
|
|
|
|
| self.scroll_area = QScrollArea()
|
| self.scroll_area.setWidgetResizable(True)
|
| self.grid_widget = QWidget()
|
| self.grid_layout = QGridLayout(self.grid_widget)
|
| self.grid_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
| self.scroll_area.setWidget(self.grid_widget)
|
| main_layout.addWidget(self.scroll_area)
|
|
|
| self.lbl_status = QLabel("Sẵn sàng.")
|
| self.lbl_status.setWordWrap(True)
|
| main_layout.addWidget(self.lbl_status)
|
|
|
| def open_folder(self):
|
| folder = QFileDialog.getExistingDirectory(self, "Chọn thư mục _frames")
|
| if not folder:
|
| return
|
| self.current_folder = folder
|
|
|
| base = os.path.basename(folder)
|
| if base.endswith("_frames"):
|
| json_name = base.replace("_frames", "_scenes.json")
|
| json_path = os.path.join(os.path.dirname(folder), json_name)
|
| else:
|
| json_path = ""
|
| if not os.path.exists(json_path):
|
| json_path, _ = QFileDialog.getOpenFileName(self, "Chọn file JSON scenes", os.path.dirname(folder), "JSON (*.json)")
|
| if not json_path:
|
| return
|
| self.current_json = json_path
|
| self.lbl_project.setText(f"Project: {base}")
|
| self.load_grid()
|
|
|
| def load_grid(self):
|
|
|
| while self.grid_layout.count():
|
| item = self.grid_layout.takeAt(0)
|
| if item.widget():
|
| item.widget().deleteLater()
|
| self.selected_widgets = []
|
| self.all_widgets = []
|
| self.last_clicked_widget = None
|
|
|
| with open(self.current_json, 'r', encoding='utf-8') as f:
|
| self.current_scene_data = json.load(f)
|
|
|
| row, col = 0, 0
|
| for info in self.current_scene_data:
|
| w = SceneImageWidget(info, self.current_folder)
|
| w.clicked.connect(self.on_widget_clicked)
|
| self.grid_layout.addWidget(w, row, col)
|
| self.all_widgets.append(w)
|
| col += 1
|
| if col >= self.MAX_COLS:
|
| col = 0
|
| row += 1
|
| self.lbl_status.setText(f"Đã tải {len(self.current_scene_data)} cảnh.")
|
|
|
| def on_widget_clicked(self, widget):
|
| modifiers = QApplication.keyboardModifiers()
|
| if modifiers == Qt.ShiftModifier and self.last_clicked_widget is not None:
|
| if widget in self.all_widgets and self.last_clicked_widget in self.all_widgets:
|
| idx1 = self.all_widgets.index(self.last_clicked_widget)
|
| idx2 = self.all_widgets.index(widget)
|
| for w in self.selected_widgets:
|
| w.set_selected(False)
|
| self.selected_widgets = []
|
| for i in range(min(idx1, idx2), max(idx1, idx2) + 1):
|
| self.all_widgets[i].set_selected(True)
|
| self.selected_widgets.append(self.all_widgets[i])
|
| else:
|
| if modifiers == Qt.ControlModifier:
|
| if widget in self.selected_widgets:
|
| widget.set_selected(False)
|
| self.selected_widgets.remove(widget)
|
| else:
|
| widget.set_selected(True)
|
| self.selected_widgets.append(widget)
|
| else:
|
| for w in self.selected_widgets:
|
| if w != widget:
|
| w.set_selected(False)
|
| widget.set_selected(True)
|
| self.selected_widgets = [widget]
|
| self.last_clicked_widget = widget
|
| self.setFocus()
|
|
|
| def _ensure_video(self):
|
| """Đảm bảo đã chọn video gốc (chỉ hỏi 1 lần)."""
|
| if self.video_path and os.path.exists(self.video_path):
|
| return True
|
| start_dir = os.path.dirname(self.current_folder) if self.current_folder else ""
|
| video_path, _ = QFileDialog.getOpenFileName(
|
| self, "Chọn Video Gốc (để chụp lại frame)",
|
| start_dir, "Video (*.mp4 *.mkv *.mov *.avi);;All files (*)")
|
| if video_path and os.path.exists(video_path):
|
| self.video_path = video_path
|
| return True
|
| return False
|
|
|
| def _set_nav_enabled(self, enabled):
|
| """Bật/tắt nút tiến lùi frame."""
|
| self.btn_prev_scene.setEnabled(enabled)
|
| self.btn_next_scene.setEnabled(enabled)
|
|
|
| def _shift_scene_frame(self, direction):
|
| """Dịch frame của scene đang chọn ±direction frame trong video (chạy nền)."""
|
| if len(self.selected_widgets) != 1:
|
| QMessageBox.information(self, "Thông báo", "Hãy chọn đúng 1 cảnh để dịch frame!")
|
| return
|
| if not self._ensure_video():
|
| return
|
|
|
| widget = self.selected_widgets[0]
|
| scene_info = widget.scene_info
|
| ts = scene_info.get("timestamp_seconds", 0)
|
|
|
|
|
| cap = cv2.VideoCapture(self.video_path)
|
| fps = cap.get(cv2.CAP_PROP_FPS)
|
| cap.release()
|
| if fps <= 0:
|
| QMessageBox.warning(self, "Lỗi", "Video không hợp lệ (FPS=0)!")
|
| return
|
|
|
| current_frame = round(ts * fps)
|
| target_frame = current_frame + direction
|
|
|
|
|
| self._set_nav_enabled(False)
|
| self.lbl_status.setText(f"⏳ Đang chụp frame #{target_frame}...")
|
|
|
| self._seek_worker = FrameSeekWorker(
|
| self.video_path, target_frame, widget.image_path,
|
| extra_data={'scene_info': scene_info, 'widget': widget, 'direction': direction}
|
| )
|
| self._seek_worker.done.connect(self._on_scene_frame_done)
|
| self._seek_worker.error.connect(self._on_scene_frame_error)
|
| self._seek_worker.start()
|
|
|
| def _on_scene_frame_done(self, result):
|
| """Callback khi chụp frame xong (chạy trên main thread)."""
|
| self._set_nav_enabled(True)
|
|
|
| extra = result['extra']
|
| scene_info = extra['scene_info']
|
| widget = extra['widget']
|
| direction = extra['direction']
|
| target_frame = result['target_frame']
|
| fps = result['fps']
|
|
|
|
|
| new_ts = round(target_frame / fps, 3)
|
| scene_info["timestamp_seconds"] = new_ts
|
| from datetime import timedelta
|
| scene_info["time_formatted"] = str(timedelta(seconds=new_ts))[:-3]
|
|
|
|
|
| for s in self.current_scene_data:
|
| if s["scene_index"] == scene_info["scene_index"]:
|
| s["timestamp_seconds"] = new_ts
|
| s["time_formatted"] = scene_info["time_formatted"]
|
| break
|
| with open(self.current_json, 'w', encoding='utf-8') as f:
|
| json.dump(self.current_scene_data, f, indent=4, ensure_ascii=False)
|
|
|
|
|
| pixmap = QPixmap(widget.image_path)
|
| widget.img_label.setPixmap(pixmap.scaled(200, 150, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
|
|
| self.lbl_status.setText(
|
| f"📸 Cảnh {scene_info['scene_index']}: frame #{target_frame}")
|
| widget.shift_label.setText(
|
| f"{'◀' if direction < 0 else '▶'} #{target_frame} ({scene_info['time_formatted']})")
|
|
|
| def _on_scene_frame_error(self, msg):
|
| """Callback khi chụp frame lỗi."""
|
| self._set_nav_enabled(True)
|
| QMessageBox.warning(self, "Lỗi", msg)
|
|
|
| def keyPressEvent(self, event):
|
| if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
| if self.selected_widgets:
|
| self._delete_selected()
|
| elif event.key() == Qt.Key_Space:
|
| if len(self.selected_widgets) > 1:
|
| self._merge_selected()
|
| elif event.key() == Qt.Key_Left:
|
| self._shift_scene_frame(-5)
|
| elif event.key() == Qt.Key_Right:
|
| self._shift_scene_frame(+5)
|
| super().keyPressEvent(event)
|
|
|
| def _merge_selected(self):
|
| sorted_w = sorted(self.selected_widgets, key=lambda w: w.scene_info["timestamp_seconds"])
|
| kept_time = sorted_w[0].scene_info["time_formatted"]
|
| self.selected_widgets = sorted_w[1:]
|
| self._delete_selected()
|
| self.lbl_status.setText(f"Đã GỘP thành công! Giữ lại cảnh [{kept_time}].")
|
|
|
| def _delete_selected(self):
|
| indices = [w.scene_info["scene_index"] for w in self.selected_widgets]
|
| for w in self.selected_widgets:
|
| if os.path.exists(w.image_path):
|
| try:
|
| os.remove(w.image_path)
|
| except:
|
| pass
|
| self.current_scene_data = [s for s in self.current_scene_data if s["scene_index"] not in indices]
|
| for new_idx, item in enumerate(self.current_scene_data, start=1):
|
| item["scene_index"] = new_idx
|
| with open(self.current_json, 'w', encoding='utf-8') as f:
|
| json.dump(self.current_scene_data, f, indent=4, ensure_ascii=False)
|
| self.lbl_status.setText(f"Đã xoá {len(indices)} ảnh.")
|
| self.load_grid()
|
|
|
|
|
|
|
| class MainWindow(QMainWindow):
|
| def __init__(self):
|
| super().__init__()
|
| self.setWindowTitle("Manga Editor & OCR (Batch Processing)")
|
| self.resize(800, 600)
|
|
|
|
|
| try:
|
| import ctypes
|
| myappid = 'manga.editor.ocr.1.0'
|
| ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
| except:
|
| pass
|
|
|
| from PyQt5.QtGui import QIcon
|
| if getattr(sys, 'frozen', False):
|
| icon_path = os.path.join(sys._MEIPASS, "iconvip.ico")
|
| else:
|
| icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "iconvip.ico")
|
|
|
| if os.path.exists(icon_path):
|
| self.setWindowIcon(QIcon(icon_path))
|
|
|
| self.queue = []
|
| self.current_idx = -1
|
| self.is_processing = False
|
| self.active_count = 0
|
| self._scene_workers = {}
|
| self._bubble_workers = {}
|
|
|
| central = QWidget()
|
| self.setCentralWidget(central)
|
| layout = QHBoxLayout(central)
|
|
|
|
|
| left = QWidget()
|
| left.setFixedWidth(320)
|
| ll = QVBoxLayout(left)
|
|
|
| self.btn_load = QPushButton("1. Chọn Nhiều File (Video/Ảnh)")
|
| self.btn_load.clicked.connect(self.load_images)
|
| self.btn_load.setMinimumHeight(40)
|
|
|
| self.btn_process = QPushButton("2. Bắt Đầu Chạy Hàng Loạt")
|
| self.btn_process.clicked.connect(self.start_batch)
|
| self.btn_process.setEnabled(False)
|
| self.btn_process.setMinimumHeight(40)
|
| self.btn_process.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
|
|
|
| self.btn_clear = QPushButton("3. Xóa Danh Sách")
|
| self.btn_clear.clicked.connect(self.clear_queue)
|
| self.btn_clear.setEnabled(False)
|
| self.btn_clear.setMinimumHeight(30)
|
| self.btn_clear.setStyleSheet("background-color: #E74C3C; color: white; font-weight: bold;")
|
|
|
|
|
| opts = QGroupBox("Tùy chọn hiển thị")
|
| ol = QVBoxLayout(opts)
|
|
|
| self.chk_ignore_floating = QCheckBox("Lọc Nhiễu (Bỏ qua Text trôi nổi không viền)")
|
| self.chk_ignore_floating.setChecked(True)
|
| self.chk_ignore_floating.setStyleSheet("color: red; font-weight: bold;")
|
| ol.addWidget(self.chk_ignore_floating)
|
|
|
| self.chk_extract = QCheckBox("Trích xuất Bong Bóng thành ảnh (.PNG)")
|
| self.chk_extract.setChecked(True)
|
| self.chk_extract.setStyleSheet("color: blue; font-weight: bold;")
|
| ol.addWidget(self.chk_extract)
|
|
|
| self.chk_turbo = QCheckBox("Tăng tốc độ xử lý (ép CPU gấp đôi công suất)")
|
| self.chk_turbo.setChecked(False)
|
| self.chk_turbo.setStyleSheet("color: green; font-weight: bold;")
|
| ol.addWidget(self.chk_turbo)
|
|
|
|
|
| self.lbl_status = QLabel("Trạng thái: Máy tính đã kết nối. Sẵn sàng!")
|
| self.lbl_status.setWordWrap(True)
|
|
|
|
|
|
|
| self.btn_editor = QPushButton("4. 🧹 Mở Trình Tẩy Xoá Bong Bóng")
|
| self.btn_editor.setMinimumHeight(40)
|
| self.btn_editor.setStyleSheet("background-color: #3498DB; color: white; font-weight: bold;")
|
| self.btn_editor.clicked.connect(self.open_bubble_editor)
|
|
|
| self.btn_scene_review = QPushButton("5. 🖼️ Duyệt & Gộp Scene")
|
| self.btn_scene_review.setMinimumHeight(40)
|
| self.btn_scene_review.setStyleSheet("background-color: #9B59B6; color: white; font-weight: bold;")
|
| self.btn_scene_review.clicked.connect(self.open_scene_review)
|
|
|
| ll.addWidget(self.btn_load)
|
| ll.addWidget(self.btn_process)
|
| ll.addWidget(self.btn_clear)
|
| ll.addWidget(self.btn_editor)
|
| ll.addWidget(self.btn_scene_review)
|
| ll.addWidget(opts)
|
|
|
|
|
| self.progress_bar = QProgressBar()
|
| self.progress_bar.setRange(0, 100)
|
| self.progress_bar.setValue(0)
|
| self.progress_bar.setTextVisible(True)
|
| self.progress_bar.setFormat("%p% - Sẵn sàng")
|
| self.progress_bar.setMinimumHeight(22)
|
| self.progress_bar.setStyleSheet("""
|
| QProgressBar {
|
| border: 1px solid #ccc;
|
| border-radius: 5px;
|
| text-align: center;
|
| font-weight: bold;
|
| background-color: #f0f0f0;
|
| }
|
| QProgressBar::chunk {
|
| background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
| stop:0 #4CAF50, stop:0.5 #2196F3, stop:1 #9C27B0);
|
| border-radius: 4px;
|
| }
|
| """)
|
| ll.addWidget(self.progress_bar)
|
|
|
| ll.addWidget(self.lbl_status)
|
| ll.addStretch()
|
|
|
|
|
| self.file_list = QListWidget()
|
| self.file_list.setStyleSheet("font-size: 14px; padding: 5px;")
|
|
|
| layout.addWidget(left)
|
| layout.addWidget(self.file_list)
|
|
|
|
|
| self.check_previous_state()
|
|
|
| def save_state(self):
|
| idx = max(0, self.current_idx)
|
| pending = self.queue[idx:]
|
| try:
|
| if pending:
|
| with open(STATE_FILE, 'w', encoding='utf-8') as f:
|
| json.dump(pending, f, ensure_ascii=False, indent=4)
|
| else:
|
| if os.path.exists(STATE_FILE):
|
| os.remove(STATE_FILE)
|
| except Exception as e:
|
| print("Lỗi lưu state:", e)
|
|
|
| def check_previous_state(self):
|
| if os.path.exists(STATE_FILE):
|
| try:
|
| with open(STATE_FILE, 'r', encoding='utf-8') as f:
|
| pending = json.load(f)
|
| if pending and isinstance(pending, list):
|
| reply = QMessageBox.question(self, 'Phát hiện tiến trình cũ',
|
| f'Hệ thống phát hiện {len(pending)} file chưa chạy xong do lần trước bị ngắt đột ngột (hoặc tắt máy).\nBạn có muốn khôi phục danh sách cũ để chạy tiếp không?',
|
| QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
| if reply == QMessageBox.Yes:
|
| self.queue = pending
|
| for fp in self.queue:
|
| self.file_list.addItem(f"[Chờ xử lý] - {os.path.basename(fp)}")
|
| self.btn_process.setEnabled(True)
|
| self.btn_clear.setEnabled(True)
|
| self.lbl_status.setText(f"Đã khôi phục {len(self.queue)} file tiến trình dang dở!")
|
| else:
|
| os.remove(STATE_FILE)
|
| except Exception as e:
|
| print("Không thể đọc state:", e)
|
|
|
| def load_images(self):
|
| file_paths, _ = QFileDialog.getOpenFileNames(self, "Chọn file", "", "Media (*.png *.jpg *.jpeg *.webp *.mp4 *.mkv *.mov *.avi)")
|
| if file_paths:
|
| for fp in file_paths:
|
| if fp not in self.queue:
|
| self.queue.append(fp)
|
| self.file_list.addItem(f"[Chờ xử lý] - {os.path.basename(fp)}")
|
|
|
| self.btn_process.setEnabled(True)
|
| self.btn_clear.setEnabled(True)
|
| self.lbl_status.setText(f"Đã tải {len(self.queue)} file vào danh sách!")
|
| self.save_state()
|
|
|
| def clear_queue(self):
|
| self.queue.clear()
|
| self.file_list.clear()
|
| self.btn_process.setEnabled(False)
|
| self.btn_clear.setEnabled(False)
|
| self.current_idx = -1
|
| self.lbl_status.setText("Đã xóa toàn bộ danh sách chờ.")
|
| self.save_state()
|
|
|
| def start_batch(self):
|
| if not self.queue: return
|
| download_font()
|
|
|
| self.btn_process.setEnabled(False)
|
| self.btn_load.setEnabled(False)
|
| self.btn_clear.setEnabled(False)
|
| self.is_processing = True
|
| self.current_idx = 0
|
| self.active_count = 0
|
| self._scene_workers = {}
|
| self._bubble_workers = {}
|
| self._current_step = 0
|
| self.progress_bar.setValue(0)
|
| self.progress_bar.setFormat("%p% - Đang xử lý...")
|
| self.process_next()
|
|
|
| def process_next(self):
|
| max_concurrent = 1
|
|
|
|
|
| while self.active_count < max_concurrent and self.current_idx < len(self.queue):
|
| idx = self.current_idx
|
| self.current_idx += 1
|
| self.active_count += 1
|
| self._launch_video(idx)
|
|
|
|
|
| if self.active_count == 0 and self.current_idx >= len(self.queue):
|
| self.lbl_status.setText("🎉 Đã hoàn thành toàn bộ danh sách!")
|
| self.is_processing = False
|
| self.btn_load.setEnabled(True)
|
| self.btn_clear.setEnabled(True)
|
|
|
| def _launch_video(self, idx):
|
| """Khởi chạy pipeline cho 1 video tại vị trí idx trong queue."""
|
| current_file = self.queue[idx]
|
| is_video = current_file.lower().endswith(('.mp4', '.mkv', '.mov', '.avi'))
|
| item = self.file_list.item(idx)
|
| item.setText(f"[Đang chạy...] - {os.path.basename(current_file)}")
|
|
|
| if is_video:
|
| self.lbl_status.setText(f"[Bước 1/2] Tách Scene: {os.path.basename(current_file)}")
|
| worker = SceneDetectionWorker(current_file)
|
| worker.progress.connect(self.update_status)
|
| worker.finished_sig.connect(lambda bound_idx=idx: self._on_scene_done_for(bound_idx))
|
| worker.start()
|
| self._scene_workers[idx] = worker
|
| else:
|
| self._launch_bubble(idx)
|
|
|
| def _on_scene_done_for(self, idx):
|
| """Scene detection xong cho video idx, chuyển sang tách bubble."""
|
| self._scene_workers.pop(idx, None)
|
| current_file = self.queue[idx]
|
| self.lbl_status.setText(f"[Bước 2/2] Tách Bubble: {os.path.basename(current_file)}")
|
| self._launch_bubble(idx)
|
|
|
| def _launch_bubble(self, idx):
|
| current_file = self.queue[idx]
|
| ignore_ft = self.chk_ignore_floating.isChecked()
|
| extract_f = self.chk_extract.isChecked()
|
| worker = WorkerThread(current_file, True, 22, 4, ignore_ft, extract_f, skip_video=True,
|
| turbo=self.chk_turbo.isChecked())
|
| worker.progress.connect(self.update_status)
|
| worker.finished.connect(lambda o, r, t, tx, b, bound_idx=idx: self._on_finished_for(bound_idx, o, r, t, tx, b))
|
| worker.error.connect(lambda e, bound_idx=idx: self._on_error_for(bound_idx, e))
|
| worker.start()
|
| self._bubble_workers[idx] = worker
|
|
|
| def update_status(self, msg):
|
| self.lbl_status.setText(msg)
|
|
|
|
|
| import re
|
| m = re.search(r'(\d+)/(\d+)', msg)
|
| if m:
|
| current = int(m.group(1))
|
| total = int(m.group(2))
|
| if total > 0:
|
| ratio = min(current / total, 1.0)
|
| if '[Scene]' in msg:
|
| pct = int(ratio * 50)
|
| self.progress_bar.setFormat(f"%p% - Bước 1: Tách Scene")
|
| else:
|
| pct = 50 + int(ratio * 50)
|
| self.progress_bar.setFormat(f"%p% - Bước 2: Tách Bubble")
|
| self.progress_bar.setValue(min(pct, 100))
|
|
|
| def _on_finished_for(self, idx, original_img, result_img, transparent_pil, texts, bboxes):
|
| self._bubble_workers.pop(idx, None)
|
| current_file = self.queue[idx]
|
|
|
| is_video = current_file.lower().endswith(('.mp4', '.mkv', '.mov', '.avi'))
|
| if not is_video:
|
| if result_img:
|
| result_img.save(current_file.rsplit('.', 1)[0] + '_result.png')
|
| if transparent_pil:
|
| transparent_pil.save(current_file.rsplit('.', 1)[0] + '_transparent.png')
|
|
|
| item = self.file_list.item(idx)
|
| item.setText(f"[Hoàn thành] - {os.path.basename(current_file)}")
|
| item.setForeground(Qt.darkGreen)
|
|
|
| self.active_count -= 1
|
| self.save_state()
|
|
|
|
|
| if self.active_count == 0 and self.current_idx >= len(self.queue):
|
| self.progress_bar.setValue(100)
|
| self.progress_bar.setFormat("%p% - Hoàn thành! 🎉")
|
|
|
| self.process_next()
|
|
|
| def _on_error_for(self, idx, err):
|
| self._bubble_workers.pop(idx, None)
|
| QMessageBox.critical(self, "Lỗi khi quét", err)
|
| item = self.file_list.item(idx)
|
| item.setText(f"[LỖI] - {os.path.basename(self.queue[idx])}")
|
| item.setForeground(Qt.red)
|
|
|
| self.active_count -= 1
|
| self.save_state()
|
| self.process_next()
|
|
|
| def open_bubble_editor(self):
|
| self.editor_win = BubbleEditorWindow(self)
|
| self.editor_win.show()
|
|
|
| def open_scene_review(self):
|
| self.scene_win = SceneReviewWindow(self)
|
| self.scene_win.show()
|
|
|
| self.btn_clear.setEnabled(True)
|
|
|
| if __name__ == '__main__':
|
| import multiprocessing
|
| multiprocessing.freeze_support()
|
| app = QApplication(sys.argv)
|
| app.setStyle('Fusion')
|
| window = MainWindow()
|
| window.show()
|
| sys.exit(app.exec_())
|
|
|