#!/usr/bin/env python3 import sys import os import traceback import logging import threading from datetime import datetime # === SETUP LOG FILE (log.txt cạnh file exe) === 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 # Bắt exception trong thread (Python 3.8+) 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) # Ngăn HuggingFace kiểm tra version trên mạng, bắt buộc chạy model local từ Cache 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 # --- TÍCH HỢP COMIC TEXT DETECTOR (PYTORCH) --- if getattr(sys, 'frozen', False): # PyInstaller 6.0+ để data trong _internal (sys._MEIPASS) 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 tiếng Nhật # Sử dụng font Tiếng Nhật có sẵn trong hệ thống Ubuntu FONT_PATH = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" def download_font(): pass # --- HỆ THỐNG XỬ LÝ ĐA TIẾN TRÌNH (MULTIPROCESSING) --- 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) # FIX: Bỏ bản copy gbg thừa (~8MB/frame), dùng transparent_pil trực tiếp transparent_arr = np.array(transparent_pil) del transparent_pil # Giải phóng PIL object ngay return (frame_idx, transparent_arr, bboxes, img_bgr, erased_img_bgr, transparent_arr, extracted_texts) # --- LUỒNG XỬ LÝ BACKGROUND (ĐỂ KHÔNG LÀM ĐƠ GIAO DIỆN) --- class WorkerThread(QThread): progress = pyqtSignal(str) finished = pyqtSignal(object, object, object, list, list) # original, result, transparent, texts, bboxes 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") # Tối ưu phân bổ luồng đa nhân (Tránh lỗi Over-subscription khiến CPU bị nghẽn ở mức 50%) 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') # === BẬT FP16 HALF PRECISION: Tăng tốc GPU ~30-50% === 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!") # Ảnh đơn: chạy tuần tự bình thường 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 # ============================================================ # === XỬ LÝ VIDEO: PIPELINE PRODUCER-CONSUMER (GPU ↔ CPU) === # ============================================================ 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 # --- Tạo thư mục trích xuất Bubbles --- 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 # --- Pipeline Queues --- # GPU thread đẩy kết quả AI vào đây, CPU workers lấy ra xử lý gpu_to_cpu_queue = queue.Queue(maxsize=12) # Buffer 12 frames giữa GPU và CPU cpu_results_queue = queue.Queue(maxsize=20) # FIX: Giới hạn 20 frames chống tràn RAM gpu_done = threading.Event() # Tín hiệu GPU đã hoàn tất stop_event = threading.Event() # Tín hiệu DỪNG khẩn cấp # ======================================== # PRODUCER (GPU Thread): Chạy liên tục AI # ======================================== 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 # Chỉ phân tích 5fps để không bị miss ảnh trôi nhanh if frame_idx % max(1, int(fps / 5)) != 0: continue # Duplicate detection 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 # === GPU INFERENCE (nhanh ~15-30ms) === 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] }) # Đẩy kết quả AI vào queue cho CPU xử lý gpu_to_cpu_queue.put((frame_idx, frame_bgr, mask_refined_uint8, pblk_list)) # Log RAM mỗi 50 frames 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() # Báo hiệu GPU xong hết # ========================================== # HYBRID DISPATCHER: Tự chọn Thread/Process # ========================================== # bg_style==4 (chỉ cắt bubble, SKIP OCR): dùng ThreadPool # → OpenCV C++ tự giải phóng GIL, không cần pickle 6MB/frame # bg_style!=4 (có chạy OCR Python nặng): dùng ProcessPool # → Phá GIL để 12 nhân chạy OCR song song thật sự use_multiprocessing = (self.bg_style != 4) def cpu_thread_worker(): """CPU worker chạy bằng Thread (cho bg_style==4, không OCR).""" # Khởi tạo OCR trong thread nếu cần 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: # FIX: Giảm từ 30 xuống 2×workers 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 # === KHỞI CHẠY PIPELINE === 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 # === MAIN THREAD: Thu thập kết quả và xuất output === json_data = [] last_saved_bgr = None results_collected = 0 while True: # Kiểm tra xem tất cả đã xong chưa 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) # Lưu Frame tĩnh (Bubble extraction) 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})...") # FIX: Gửi tín hiệu stop rõ ràng trước khi join 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) # --- SCENE DETECTION WORKER (Tách từ tach-frame.py) --- 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: # FIX: Wrap trong try/finally để đảm bảo cap.release() 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 # Chỉ số bù đắp cho tần số quét 5fps thay vì 2fps (để không lọt cảnh) scan_fps = 5 frame_step = max(1, int(fps / scan_fps)) window_frames = max(3, int((fps * 0.4) / frame_step)) # ~0.4 giấy buffer 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.") # --- BUBBLE ERASER EDITOR --- 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) # Pan offset 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 # FIX: Cache nền caro 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 # Center the image 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 # OpenCV floodFill chỉ hỗ trợ ảnh RGB 3-kênh, nên tách 3 kênh đầu ra rgb_img = cv_img[:, :, :3].copy() # Tạo mask (yêu cầu của openCV là phải rộng hơn ảnh 2 pixel) mask = np.zeros((h + 2, w + 2), np.uint8) seed_pt = (img_x, img_y) # Do nền truyện tranh không phải lúc nào cũng trắng tinh khôi, cho sai số dao động 30 tol = (30, 30, 30) # Sử dụng cờ MASK_ONLY để openCV trả ra khu vực màu giống nhau vào file `mask` thay vì đổi màu ảnh RGB 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) # Cắt mask về đúng size ảnh chuẩn fill_mask = mask[1:-1, 1:-1] # Dùng mask để đục thủng ảnh RGBA (biến thành trong suốt) 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) # Bresenham-style interpolation 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) # FIX: Vẽ nền caro từ cache (thay vì 3300 fillRect mỗi frame) 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() # Vẽ con trỏ cọ tẩy 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: # Kích hoạt Đũa Thần (Magic Wand) 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() # Cập nhật vòng tròn con trỏ 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)) # Zoom về phía con trỏ chuột 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) # fname, fpath, QPixmap progress_sig = pyqtSignal(int, int) # current, total done_sig = pyqtSignal(int) # total count 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') # Nền caro siêu nhanh bằng NumPy (thay vì pixel-by-pixel) 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 compositing 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 = "" # Path video gốc (chọn 1 lần) self.frame_offset = 0 # Offset hiện tại so với frame gốc central = QWidget() self.setCentralWidget(central) main_layout = QHBoxLayout(central) # --- LEFT PANEL: Thumbnail grid --- 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) # Thumbnail list 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) # Nút xoá file 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: Canvas + Controls --- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) # Toolbar row 1: Brush + Save + Undo 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) # Toolbar row 2: Frame recapture 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) # Canvas 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) # Shortcuts 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 # Bản gốc để Undo 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 # Khóa nút + hiện loading 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 # rollback 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): # Auto-save ảnh trước đó nếu đã sửa 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() # Cập nhật chỉ đúng 1 thumbnail thay vì reload tất cả 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) # Cập nhật danh sách nội bộ thay vì reload cả folder 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}") # --- WORKER CHỤP LẠI FRAME (Chạy nền, không đơ UI) --- class FrameSeekWorker(QThread): """Thread nền: seek video + đọc frame + lưu ảnh.""" done = pyqtSignal(object) # (frame_bgr, save_path, extra_data) 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 # Lưu ảnh 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)) # --- SCENE REVIEW WINDOW (Gộp từ tach-frame.py) --- 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 = "" # Path video gốc (chọn 1 lần) central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) # Top bar 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) # Nút tiến/lùi frame 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) # Grid inside scroll 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 # Tìm json tương ứng: nếu folder là X_frames thì json là X_scenes.json 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): # Clear old 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) # Tính target frame (cần biết fps → mở video nhanh chỉ đọc metadata) 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 # Khóa nút + hiện loading 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'] # Cập nhật timestamp 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] # Cập nhật JSON 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) # Cập nhật thumbnail 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() # --- GIAO DIỆN CHÍNH (SÁNG) --- class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Manga Editor & OCR (Batch Processing)") self.resize(800, 600) # --- Bật Icon cho Taskbar và Cửa sổ --- 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) # Thanh bên trái 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;") # Nhóm Tùy Chọn 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) # Ghép giao diện trái # Nút mở Bubble Editor 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) # Thanh tiến độ tổng hợp 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() # Đẩy các thành phần lên trên # Thanh bên phải xem danh sách hàng đợi self.file_list = QListWidget() self.file_list.setStyleSheet("font-size: 14px; padding: 5px;") layout.addWidget(left) layout.addWidget(self.file_list) # Khôi phục từ sập nguồn 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 # 0=chưa bắt đầu, 1=scene, 2=bubble self.progress_bar.setValue(0) self.progress_bar.setFormat("%p% - Đang xử lý...") self.process_next() def process_next(self): max_concurrent = 1 # Luôn chạy 1 video 1 lúc, turbo dành cho CPU workers # Khởi chạy thêm video nếu còn slot trống 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) # Kiểm tra xem tất cả đã xong chưa 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) # Parse progress từ log messages để cập nhật thanh tiến độ 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() # Cập nhật progress bar khi xong 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() # Thử khởi chạy video tiếp theo 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() # Tiếp tục xử lý video khác dù 1 video lỗi 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') # Chọn theme sáng cơ bản window = MainWindow() window.show() sys.exit(app.exec_())