|
|
""" |
|
|
Panel Extractor - Extracts and saves individual comic panels as 640x800 images |
|
|
""" |
|
|
|
|
|
import os |
|
|
import json |
|
|
import cv2 |
|
|
import numpy as np |
|
|
from PIL import Image, ImageDraw, ImageFont |
|
|
from typing import List, Dict, Tuple |
|
|
|
|
|
class PanelExtractor: |
|
|
def __init__(self, output_dir: str = "output/panels"): |
|
|
"""Initialize panel extractor |
|
|
|
|
|
Args: |
|
|
output_dir: Directory to save extracted panels |
|
|
""" |
|
|
self.output_dir = output_dir |
|
|
self.panel_size = (640, 800) |
|
|
|
|
|
def extract_panels_from_comic(self, pages_json_path: str = "output/pages.json", |
|
|
frames_dir: str = "frames/final") -> List[str]: |
|
|
"""Extract panels from generated comic data |
|
|
|
|
|
Args: |
|
|
pages_json_path: Path to pages.json file |
|
|
frames_dir: Directory containing frame images |
|
|
|
|
|
Returns: |
|
|
List of saved panel file paths |
|
|
""" |
|
|
|
|
|
os.makedirs(self.output_dir, exist_ok=True) |
|
|
|
|
|
|
|
|
for file in os.listdir(self.output_dir): |
|
|
if file.endswith('.jpg') or file.endswith('.png'): |
|
|
os.remove(os.path.join(self.output_dir, file)) |
|
|
|
|
|
|
|
|
try: |
|
|
with open(pages_json_path, 'r') as f: |
|
|
pages_data = json.load(f) |
|
|
except Exception as e: |
|
|
print(f"❌ Failed to load comic data: {e}") |
|
|
return [] |
|
|
|
|
|
saved_panels = [] |
|
|
panel_count = 0 |
|
|
|
|
|
print(f"📸 Extracting panels as {self.panel_size[0]}x{self.panel_size[1]} images...") |
|
|
|
|
|
|
|
|
for page_idx, page in enumerate(pages_data): |
|
|
panels = page.get('panels', []) |
|
|
bubbles = page.get('bubbles', []) |
|
|
|
|
|
|
|
|
for panel_idx, panel in enumerate(panels): |
|
|
panel_count += 1 |
|
|
|
|
|
|
|
|
panel_img = self._extract_panel(panel, frames_dir) |
|
|
if panel_img is None: |
|
|
continue |
|
|
|
|
|
|
|
|
panel_bubbles = self._find_panel_bubbles(panel, bubbles) |
|
|
|
|
|
|
|
|
if panel_bubbles: |
|
|
panel_img = self._add_bubbles_to_panel(panel_img, panel, panel_bubbles) |
|
|
|
|
|
|
|
|
panel_img = self._resize_panel(panel_img) |
|
|
|
|
|
|
|
|
filename = f"panel_{panel_count:03d}_p{page_idx+1}_{panel_idx+1}.jpg" |
|
|
filepath = os.path.join(self.output_dir, filename) |
|
|
|
|
|
|
|
|
if len(panel_img.shape) == 3 and panel_img.shape[2] == 4: |
|
|
panel_img = cv2.cvtColor(panel_img, cv2.COLOR_BGRA2BGR) |
|
|
elif len(panel_img.shape) == 2: |
|
|
panel_img = cv2.cvtColor(panel_img, cv2.COLOR_GRAY2BGR) |
|
|
|
|
|
cv2.imwrite(filepath, panel_img, [cv2.IMWRITE_JPEG_QUALITY, 95]) |
|
|
saved_panels.append(filepath) |
|
|
|
|
|
print(f"✅ Extracted {len(saved_panels)} panels to: {self.output_dir}") |
|
|
|
|
|
|
|
|
self._create_panel_viewer(saved_panels) |
|
|
|
|
|
return saved_panels |
|
|
|
|
|
def _extract_panel(self, panel: Dict, frames_dir: str) -> np.ndarray: |
|
|
"""Extract panel region from frame image""" |
|
|
try: |
|
|
|
|
|
frame_filename = os.path.basename(panel['image']) |
|
|
frame_path = os.path.join(frames_dir, frame_filename) |
|
|
|
|
|
if not os.path.exists(frame_path): |
|
|
|
|
|
frame_path = panel['image'].lstrip('/') |
|
|
if not os.path.exists(frame_path): |
|
|
print(f"⚠️ Frame not found: {frame_path}") |
|
|
return None |
|
|
|
|
|
|
|
|
frame = cv2.imread(frame_path) |
|
|
if frame is None: |
|
|
print(f"⚠️ Failed to load frame: {frame_path}") |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
return frame |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ Failed to extract panel: {e}") |
|
|
return None |
|
|
|
|
|
def _find_panel_bubbles(self, panel: Dict, bubbles: List[Dict]) -> List[Dict]: |
|
|
"""Find speech bubbles that belong to a panel""" |
|
|
panel_bubbles = [] |
|
|
|
|
|
|
|
|
px1 = panel['x'] |
|
|
py1 = panel['y'] |
|
|
px2 = px1 + panel['width'] |
|
|
py2 = py1 + panel['height'] |
|
|
|
|
|
for bubble in bubbles: |
|
|
|
|
|
bx = bubble['x'] + bubble['width'] / 2 |
|
|
by = bubble['y'] + bubble['height'] / 2 |
|
|
|
|
|
|
|
|
if px1 <= bx <= px2 and py1 <= by <= py2: |
|
|
|
|
|
adjusted_bubble = bubble.copy() |
|
|
adjusted_bubble['x'] -= px1 |
|
|
adjusted_bubble['y'] -= py1 |
|
|
panel_bubbles.append(adjusted_bubble) |
|
|
|
|
|
return panel_bubbles |
|
|
|
|
|
def _add_bubbles_to_panel(self, panel_img: np.ndarray, panel: Dict, |
|
|
bubbles: List[Dict]) -> np.ndarray: |
|
|
"""Add speech bubbles to panel image""" |
|
|
|
|
|
img = Image.fromarray(cv2.cvtColor(panel_img, cv2.COLOR_BGR2RGB)) |
|
|
draw = ImageDraw.Draw(img) |
|
|
|
|
|
|
|
|
try: |
|
|
font = ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 16) |
|
|
except: |
|
|
font = None |
|
|
|
|
|
for bubble in bubbles: |
|
|
|
|
|
img_h, img_w = panel_img.shape[:2] |
|
|
panel_w, panel_h = panel['width'], panel['height'] |
|
|
|
|
|
|
|
|
scale_x = img_w / panel_w |
|
|
scale_y = img_h / panel_h |
|
|
|
|
|
|
|
|
x = int(bubble['x'] * scale_x) |
|
|
y = int(bubble['y'] * scale_y) |
|
|
w = int(bubble['width'] * scale_x) |
|
|
h = int(bubble['height'] * scale_y) |
|
|
|
|
|
|
|
|
bubble_bbox = [x, y, x + w, y + h] |
|
|
draw.ellipse(bubble_bbox, fill='white', outline='black', width=2) |
|
|
|
|
|
|
|
|
text = bubble.get('text', '') |
|
|
if text and font: |
|
|
|
|
|
words = text.split() |
|
|
lines = [] |
|
|
current_line = [] |
|
|
|
|
|
for word in words: |
|
|
current_line.append(word) |
|
|
line_text = ' '.join(current_line) |
|
|
bbox = draw.textbbox((0, 0), line_text, font=font) |
|
|
if bbox[2] > w - 20: |
|
|
if len(current_line) > 1: |
|
|
current_line.pop() |
|
|
lines.append(' '.join(current_line)) |
|
|
current_line = [word] |
|
|
else: |
|
|
lines.append(line_text) |
|
|
current_line = [] |
|
|
|
|
|
if current_line: |
|
|
lines.append(' '.join(current_line)) |
|
|
|
|
|
|
|
|
line_height = 20 |
|
|
total_height = len(lines) * line_height |
|
|
start_y = y + (h - total_height) // 2 |
|
|
|
|
|
for i, line in enumerate(lines): |
|
|
bbox = draw.textbbox((0, 0), line, font=font) |
|
|
text_width = bbox[2] - bbox[0] |
|
|
text_x = x + (w - text_width) // 2 |
|
|
text_y = start_y + i * line_height |
|
|
draw.text((text_x, text_y), line, fill='black', font=font) |
|
|
|
|
|
|
|
|
return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) |
|
|
|
|
|
def _resize_panel(self, panel_img: np.ndarray) -> np.ndarray: |
|
|
"""Resize panel to target size (640x800)""" |
|
|
h, w = panel_img.shape[:2] |
|
|
target_w, target_h = self.panel_size |
|
|
|
|
|
|
|
|
scale = min(target_w / w, target_h / h) |
|
|
new_w = int(w * scale) |
|
|
new_h = int(h * scale) |
|
|
|
|
|
|
|
|
resized = cv2.resize(panel_img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) |
|
|
|
|
|
|
|
|
canvas = np.ones((target_h, target_w, 3), dtype=np.uint8) * 255 |
|
|
|
|
|
|
|
|
x_offset = (target_w - new_w) // 2 |
|
|
y_offset = (target_h - new_h) // 2 |
|
|
|
|
|
canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized |
|
|
|
|
|
return canvas |
|
|
|
|
|
def _create_panel_viewer(self, panel_files: List[str]): |
|
|
"""Create an HTML viewer for extracted panels""" |
|
|
html = '''<!DOCTYPE html> |
|
|
<html> |
|
|
<head> |
|
|
<title>Extracted Comic Panels - 640x800</title> |
|
|
<style> |
|
|
body { |
|
|
margin: 0; |
|
|
padding: 20px; |
|
|
background: #1a1a1a; |
|
|
color: white; |
|
|
font-family: Arial, sans-serif; |
|
|
} |
|
|
h1 { |
|
|
text-align: center; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
.panel-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); |
|
|
gap: 20px; |
|
|
max-width: 1400px; |
|
|
margin: 0 auto; |
|
|
} |
|
|
.panel-card { |
|
|
background: #2a2a2a; |
|
|
border-radius: 8px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.3); |
|
|
transition: transform 0.3s; |
|
|
} |
|
|
.panel-card:hover { |
|
|
transform: scale(1.05); |
|
|
} |
|
|
.panel-card img { |
|
|
width: 100%; |
|
|
height: auto; |
|
|
display: block; |
|
|
} |
|
|
.panel-info { |
|
|
padding: 10px; |
|
|
text-align: center; |
|
|
font-size: 14px; |
|
|
color: #aaa; |
|
|
} |
|
|
.download-all { |
|
|
display: block; |
|
|
margin: 20px auto; |
|
|
padding: 10px 30px; |
|
|
background: #4CAF50; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 5px; |
|
|
font-size: 16px; |
|
|
cursor: pointer; |
|
|
text-decoration: none; |
|
|
text-align: center; |
|
|
max-width: 200px; |
|
|
} |
|
|
.download-all:hover { |
|
|
background: #45a049; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<h1>📸 Extracted Comic Panels (640x800)</h1> |
|
|
<p style="text-align: center; color: #888;">All panels have been extracted and resized to 640x800 pixels</p> |
|
|
|
|
|
<div class="panel-grid"> |
|
|
''' |
|
|
|
|
|
for panel_path in panel_files: |
|
|
filename = os.path.basename(panel_path) |
|
|
panel_num = filename.split('_')[1] |
|
|
|
|
|
html += f''' |
|
|
<div class="panel-card"> |
|
|
<img src="{filename}" alt="{filename}"> |
|
|
<div class="panel-info">Panel {panel_num}</div> |
|
|
</div> |
|
|
''' |
|
|
|
|
|
html += ''' |
|
|
</div> |
|
|
</body> |
|
|
</html>''' |
|
|
|
|
|
viewer_path = os.path.join(self.output_dir, 'panel_viewer.html') |
|
|
with open(viewer_path, 'w', encoding='utf-8') as f: |
|
|
f.write(html) |
|
|
|
|
|
print(f"📄 Panel viewer created: {viewer_path}") |
|
|
|
|
|
|
|
|
|
|
|
def extract_panels(pages_json: str = "output/pages.json", |
|
|
frames_dir: str = "frames/final", |
|
|
output_dir: str = "output/panels"): |
|
|
"""Extract panels from comic""" |
|
|
extractor = PanelExtractor(output_dir) |
|
|
return extractor.extract_panels_from_comic(pages_json, frames_dir) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
extract_panels() |