|
|
import gradio as gr
|
|
|
import cv2
|
|
|
import numpy as np
|
|
|
import os
|
|
|
import zipfile
|
|
|
from pathlib import Path
|
|
|
import tempfile
|
|
|
import shutil
|
|
|
from typing import List, Tuple, Optional
|
|
|
|
|
|
class MangaWebSplitter:
|
|
|
def __init__(self):
|
|
|
self.temp_dir = None
|
|
|
|
|
|
def detect_separators(self, image: np.ndarray,
|
|
|
white_threshold: int = 240,
|
|
|
black_threshold: int = 15,
|
|
|
min_separator_height: int = 15) -> List[int]:
|
|
|
"""
|
|
|
Phát hiện các vùng separator (trắng hoặc đen) để cắt
|
|
|
"""
|
|
|
if len(image.shape) == 3:
|
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
|
else:
|
|
|
gray = image.copy()
|
|
|
|
|
|
height, width = gray.shape
|
|
|
split_points = []
|
|
|
|
|
|
|
|
|
for y in range(0, height, 3):
|
|
|
row = gray[y, :]
|
|
|
|
|
|
|
|
|
white_pixels = np.sum(row > white_threshold)
|
|
|
black_pixels = np.sum(row < black_threshold)
|
|
|
white_ratio = white_pixels / width
|
|
|
black_ratio = black_pixels / width
|
|
|
|
|
|
|
|
|
is_separator = white_ratio > 0.85 or black_ratio > 0.85
|
|
|
|
|
|
if is_separator:
|
|
|
|
|
|
separator_height = 0
|
|
|
separator_type = "white" if white_ratio > black_ratio else "black"
|
|
|
|
|
|
for check_y in range(y, min(y + 50, height)):
|
|
|
check_row = gray[check_y, :]
|
|
|
if separator_type == "white":
|
|
|
check_ratio = np.sum(check_row > white_threshold) / width
|
|
|
else:
|
|
|
check_ratio = np.sum(check_row < black_threshold) / width
|
|
|
|
|
|
if check_ratio > 0.85:
|
|
|
separator_height += 1
|
|
|
else:
|
|
|
break
|
|
|
|
|
|
|
|
|
if separator_height >= min_separator_height:
|
|
|
split_points.append(y + separator_height // 2)
|
|
|
|
|
|
|
|
|
filtered_points = []
|
|
|
for point in split_points:
|
|
|
if not filtered_points or point - filtered_points[-1] > 100:
|
|
|
filtered_points.append(point)
|
|
|
|
|
|
return filtered_points
|
|
|
|
|
|
def split_manga(self,
|
|
|
image_file,
|
|
|
max_height: int = None,
|
|
|
white_threshold: int = 240,
|
|
|
black_threshold: int = 15,
|
|
|
min_separator_height: int = 15,
|
|
|
show_preview: bool = False,
|
|
|
auto_height: bool = True) -> Tuple[str, str, str]:
|
|
|
"""
|
|
|
Cắt ảnh manga và trả về kết quả
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
image = cv2.imread(image_file)
|
|
|
if image is None:
|
|
|
return "❌ Không thể đọc file ảnh! Vui lòng kiểm tra định dạng.", "", ""
|
|
|
|
|
|
height, width = image.shape[:2]
|
|
|
|
|
|
|
|
|
if auto_height or max_height is None or max_height == 0:
|
|
|
if height <= 2000:
|
|
|
calculated_height = max(height // 2, 800)
|
|
|
elif height <= 4000:
|
|
|
calculated_height = max(height // 3, 1000)
|
|
|
elif height <= 6000:
|
|
|
calculated_height = 2000
|
|
|
elif height <= 10000:
|
|
|
calculated_height = 2500
|
|
|
else:
|
|
|
calculated_height = height // (height // 2000)
|
|
|
|
|
|
max_height = calculated_height
|
|
|
auto_mode_text = f" (Tự động tối ưu)"
|
|
|
else:
|
|
|
auto_mode_text = " (Thủ công)"
|
|
|
|
|
|
|
|
|
split_points = self.detect_separators(
|
|
|
image, white_threshold, black_threshold, min_separator_height
|
|
|
)
|
|
|
|
|
|
|
|
|
if not split_points:
|
|
|
split_points = list(range(max_height, height, max_height))
|
|
|
info_msg = f"🔄 Không tìm thấy vùng separator, cắt theo chiều cao {max_height}px{auto_mode_text}"
|
|
|
else:
|
|
|
info_msg = f"✅ Tìm thấy {len(split_points)} vùng separator tự động{auto_mode_text}"
|
|
|
|
|
|
|
|
|
all_points = [0] + split_points + [height]
|
|
|
all_points = sorted(list(set(all_points)))
|
|
|
|
|
|
|
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
|
output_files = []
|
|
|
base_name = Path(image_file).stem
|
|
|
|
|
|
|
|
|
valid_parts = 0
|
|
|
for i in range(len(all_points) - 1):
|
|
|
start_y = all_points[i]
|
|
|
end_y = all_points[i + 1]
|
|
|
|
|
|
|
|
|
if end_y - start_y < 100:
|
|
|
continue
|
|
|
|
|
|
valid_parts += 1
|
|
|
cropped = image[start_y:end_y, :]
|
|
|
|
|
|
|
|
|
output_file = os.path.join(self.temp_dir, f"{base_name}_part_{valid_parts:03d}.png")
|
|
|
cv2.imwrite(output_file, cropped)
|
|
|
output_files.append(output_file)
|
|
|
|
|
|
|
|
|
zip_path = os.path.join(self.temp_dir, f"{base_name}_split.zip")
|
|
|
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
|
|
for file in output_files:
|
|
|
zipf.write(file, os.path.basename(file))
|
|
|
|
|
|
result_msg = f"""
|
|
|
📊 **Kết quả cắt ảnh:**
|
|
|
- Kích thước gốc: {width}×{height}px
|
|
|
- Chiều cao mỗi phần: {max_height}px{auto_mode_text}
|
|
|
- Số phần đã cắt: {valid_parts}
|
|
|
- Điểm cắt: {split_points if split_points else 'Theo chiều cao cố định'}
|
|
|
|
|
|
{info_msg}
|
|
|
""".strip()
|
|
|
|
|
|
|
|
|
preview_path = ""
|
|
|
if show_preview and split_points:
|
|
|
preview_image = image.copy()
|
|
|
for point in split_points:
|
|
|
cv2.line(preview_image, (0, point), (width, point), (0, 0, 255), 3)
|
|
|
preview_path = os.path.join(self.temp_dir, "preview.png")
|
|
|
cv2.imwrite(preview_path, preview_image)
|
|
|
|
|
|
return result_msg, zip_path, preview_path
|
|
|
|
|
|
except Exception as e:
|
|
|
return f"❌ Lỗi: {str(e)}", "", ""
|
|
|
|
|
|
|
|
|
splitter = MangaWebSplitter()
|
|
|
|
|
|
def process_manga(image_file, auto_height, max_height, white_threshold, black_threshold, min_separator_height, show_preview):
|
|
|
"""Xử lý ảnh manga"""
|
|
|
if image_file is None:
|
|
|
return "⚠️ Vui lòng upload ảnh trước!", None, None
|
|
|
|
|
|
result_msg, zip_path, preview_path = splitter.split_manga(
|
|
|
image_file, max_height, white_threshold, black_threshold, min_separator_height, show_preview, auto_height
|
|
|
)
|
|
|
|
|
|
return result_msg, zip_path if zip_path else None, preview_path if preview_path else None
|
|
|
|
|
|
|
|
|
css = """
|
|
|
.container {
|
|
|
max-width: 1200px;
|
|
|
margin: 0 auto;
|
|
|
}
|
|
|
.header {
|
|
|
text-align: center;
|
|
|
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
|
|
|
color: white;
|
|
|
padding: 2rem;
|
|
|
border-radius: 10px;
|
|
|
margin-bottom: 2rem;
|
|
|
}
|
|
|
.upload-area {
|
|
|
border: 2px dashed #667eea;
|
|
|
border-radius: 10px;
|
|
|
padding: 2rem;
|
|
|
text-align: center;
|
|
|
background: #f8f9ff;
|
|
|
}
|
|
|
"""
|
|
|
|
|
|
|
|
|
with gr.Blocks(css=css, title="🎨 Manga Splitter Pro") as app:
|
|
|
|
|
|
gr.HTML("""
|
|
|
<div class="header">
|
|
|
<h1>🎨 MANGA SPLITTER PRO</h1>
|
|
|
<p>Công cụ cắt ảnh manga thông minh - Tự động phát hiện vùng trắng & đen</p>
|
|
|
<p>Tránh cắt trùng vào bóng thoại • Hỗ trợ OCR tối ưu</p>
|
|
|
</div>
|
|
|
""")
|
|
|
|
|
|
with gr.Row():
|
|
|
with gr.Column(scale=1):
|
|
|
gr.HTML("<h3>📤 Upload & Cài đặt</h3>")
|
|
|
|
|
|
|
|
|
image_input = gr.File(
|
|
|
label="🖼️ Chọn ảnh manga",
|
|
|
file_types=[".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"],
|
|
|
type="filepath"
|
|
|
)
|
|
|
|
|
|
|
|
|
with gr.Accordion("⚙️ Cài đặt nâng cao", open=False):
|
|
|
auto_height = gr.Checkbox(
|
|
|
label="🤖 Tự động điều chỉnh chiều cao tối ưu",
|
|
|
value=True,
|
|
|
info="Tự động tính chiều cao phù hợp dựa trên kích thước ảnh"
|
|
|
)
|
|
|
|
|
|
max_height = gr.Slider(
|
|
|
minimum=500, maximum=5000, value=2000, step=100,
|
|
|
label="📏 Chiều cao tối đa mỗi phần (px)",
|
|
|
visible=False
|
|
|
)
|
|
|
|
|
|
white_threshold = gr.Slider(
|
|
|
minimum=200, maximum=255, value=240, step=5,
|
|
|
label="⚪ Ngưỡng pixel trắng"
|
|
|
)
|
|
|
|
|
|
black_threshold = gr.Slider(
|
|
|
minimum=0, maximum=50, value=15, step=5,
|
|
|
label="⚫ Ngưỡng pixel đen"
|
|
|
)
|
|
|
|
|
|
min_separator_height = gr.Slider(
|
|
|
minimum=5, maximum=50, value=15, step=5,
|
|
|
label="📐 Chiều cao tối thiểu vùng separator"
|
|
|
)
|
|
|
|
|
|
show_preview = gr.Checkbox(
|
|
|
label="👁️ Hiển thị preview điểm cắt",
|
|
|
value=True
|
|
|
)
|
|
|
|
|
|
|
|
|
process_btn = gr.Button(
|
|
|
"🚀 Cắt ảnh manga",
|
|
|
variant="primary",
|
|
|
size="lg"
|
|
|
)
|
|
|
|
|
|
with gr.Column(scale=1):
|
|
|
gr.HTML("<h3>📊 Kết quả</h3>")
|
|
|
|
|
|
|
|
|
result_text = gr.Markdown(
|
|
|
value="👋 Chào mừng! Upload ảnh manga để bắt đầu cắt.",
|
|
|
label="📈 Thông tin"
|
|
|
)
|
|
|
|
|
|
|
|
|
download_file = gr.File(
|
|
|
label="💾 Tải xuống file ZIP",
|
|
|
visible=False
|
|
|
)
|
|
|
|
|
|
|
|
|
preview_image = gr.Image(
|
|
|
label="👁️ Preview điểm cắt",
|
|
|
visible=False
|
|
|
)
|
|
|
|
|
|
|
|
|
with gr.Accordion("📚 Hướng dẫn sử dụng", open=False):
|
|
|
gr.Markdown("""
|
|
|
### 🎯 Cách sử dụng:
|
|
|
1. **Upload ảnh** manga (JPG, PNG) vào khung bên trái
|
|
|
2. **Điều chỉnh cài đặt** nếu cần (mặc định đã tối ưu)
|
|
|
3. **Bấm "Cắt ảnh manga"** để xử lý
|
|
|
4. **Tải xuống** file ZIP chứa các phần đã cắt
|
|
|
|
|
|
### ⚙️ Tham số:
|
|
|
- **Chiều cao tối đa**: Giới hạn chiều cao mỗi phần cắt
|
|
|
- **Ngưỡng trắng**: Độ trắng để nhận diện vùng separator (240 = rất trắng)
|
|
|
- **Ngưỡng đen**: Độ đen để nhận diện vùng dramatic (15 = rất đen)
|
|
|
- **Chiều cao separator**: Vùng separator phải cao ít nhất bao nhiêu pixel
|
|
|
|
|
|
### 🎨 Hoạt động với:
|
|
|
- ✅ Manga truyền thống (nền trắng)
|
|
|
- ✅ Action manga (vùng đen dramatic)
|
|
|
- ✅ Webtoon dài
|
|
|
- ✅ Raw scan chất lượng cao
|
|
|
""")
|
|
|
|
|
|
|
|
|
def toggle_manual_height(auto_mode):
|
|
|
return gr.update(visible=not auto_mode, value=2000)
|
|
|
|
|
|
auto_height.change(
|
|
|
toggle_manual_height,
|
|
|
inputs=[auto_height],
|
|
|
outputs=[max_height]
|
|
|
)
|
|
|
|
|
|
process_btn.click(
|
|
|
fn=process_manga,
|
|
|
inputs=[image_input, auto_height, max_height, white_threshold, black_threshold, min_separator_height, show_preview],
|
|
|
outputs=[result_text, download_file, preview_image]
|
|
|
).then(
|
|
|
|
|
|
lambda zip_file, preview_img: (
|
|
|
gr.update(visible=zip_file is not None),
|
|
|
gr.update(visible=preview_img is not None)
|
|
|
),
|
|
|
inputs=[download_file, preview_image],
|
|
|
outputs=[download_file, preview_image]
|
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
app.launch(
|
|
|
server_name="0.0.0.0",
|
|
|
server_port=7860,
|
|
|
share=False,
|
|
|
debug=True
|
|
|
)
|
|
|
|