ason / manga_editor.py
chumdz97's picture
Add files using upload-large-folder tool
a151792 verified
Raw
History Blame Contribute Delete
101 kB
#!/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_())