| """ |
| 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() |