import cv2 import numpy as np from pathlib import Path try: import matplotlib.pyplot as plt except Exception: plt = None class PanelColorExtractor: """ Extracts colored content from manga/manhwa panels by filtering out speech bubbles using HSV color space analysis and finding the actual content boundaries. """ def __init__(self, saturation_threshold=20, value_range=(30, 225), min_content_area=0.1, padding=5): """ Initialize the color extractor. Args: saturation_threshold (int): Minimum saturation to consider as colored content value_range (tuple): Range of brightness values to consider (min, max) min_content_area (float): Minimum area ratio to consider as valid content padding (int): Padding to add around detected content boundaries """ self.saturation_threshold = saturation_threshold self.value_min = value_range[0] self.value_max = value_range[1] self.min_content_area = min_content_area self.padding = padding # Create kernels for morphological operations self.morph_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) # Store intermediate results self.original_panel = None self.hsv_image = None self.color_mask = None self.content_box = None self.refined_panel = None def load_panel(self, image_path): """Load the panel image.""" try: self.original_panel = cv2.imread(str(image_path)) if self.original_panel is None: raise ValueError(f"Could not read image file: {image_path}") # Convert to HSV for color analysis self.hsv_image = cv2.cvtColor(self.original_panel, cv2.COLOR_BGR2HSV) print(f"Loaded panel: {image_path}, size: {self.original_panel.shape}") return self.original_panel except Exception as e: print(f"Error loading panel: {e}") return None def extract_colored_content(self): """ Extract colored content from the panel, excluding grayscale areas like speech bubbles. """ if self.hsv_image is None: print("Error: Panel not loaded.") return None # Get HSV channels h, s, v = cv2.split(self.hsv_image) # 1. Create a mask for colored pixels (sufficient saturation) saturation_mask = cv2.threshold(s, self.saturation_threshold, 255, cv2.THRESH_BINARY)[1] # 2. Create a mask for reasonable brightness range (exclude pure white/black) value_mask = cv2.inRange(v, self.value_min, self.value_max) # 3. Combine masks to get colored content self.color_mask = cv2.bitwise_and(saturation_mask, value_mask) # 4. Clean up the mask with morphological operations self.color_mask = cv2.morphologyEx(self.color_mask, cv2.MORPH_CLOSE, self.morph_kernel) self.color_mask = cv2.morphologyEx(self.color_mask, cv2.MORPH_OPEN, self.morph_kernel) # 5. Dilate to connect nearby regions self.color_mask = cv2.dilate(self.color_mask, self.morph_kernel, iterations=2) return self.color_mask def find_content_boundaries(self): """ Find the bounding box of the colored content. """ if self.color_mask is None: print("Error: Color mask not created.") return None # Find contours in the color mask contours, _ = cv2.findContours( self.color_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: print("Warning: No colored content detected.") h, w = self.original_panel.shape[:2] self.content_box = (0, 0, w, h) return self.content_box # Filter significant contours (by area) img_area = self.original_panel.shape[0] * self.original_panel.shape[1] min_area = img_area * self.min_content_area significant_contours = [c for c in contours if cv2.contourArea(c) > min_area] if not significant_contours: print("Warning: No significant colored regions found.") significant_contours = contours # Use all contours if none are significant enough # Create a mask with all significant contours content_mask = np.zeros_like(self.color_mask) cv2.drawContours(content_mask, significant_contours, -1, 255, -1) # Find the bounding rectangle containing all colored content x, y, w, h = cv2.boundingRect(content_mask) # Add padding (ensuring we stay within image bounds) img_h, img_w = self.original_panel.shape[:2] x = max(0, x - self.padding) y = max(0, y - self.padding) w = min(img_w - x, w + (self.padding * 2)) h = min(img_h - y, h + (self.padding * 2)) self.content_box = (x, y, w, h) return self.content_box def extract_refined_panel(self): """ Extract the portion of the panel containing the colored content. """ if self.content_box is None or self.original_panel is None: print("Error: Content boundaries not determined.") return None x, y, w, h = self.content_box # Create refined panel by cropping the original self.refined_panel = self.original_panel[y:y+h, x:x+w].copy() return self.refined_panel def process_panel(self, image_path, output_path=None, visualize=False): """ Process a panel to extract colored content. Args: image_path: Path to the panel image output_path: Optional path to save the refined panel visualize: Whether to display visualization Returns: The refined panel image """ # Load the panel if self.load_panel(image_path) is None: return None # Extract colored content self.extract_colored_content() # Find content boundaries self.find_content_boundaries() # Extract refined panel self.extract_refined_panel() # Save the result if path provided if output_path: output_path_obj = Path(output_path) # Ensure output directory exists if not output_path_obj.suffix: # Directory path output_dir = output_path_obj output_dir.mkdir(parents=True, exist_ok=True) # Create filename based on input input_filename = Path(image_path).stem output_filename = f"{input_filename}_refined.png" output_path_full = output_dir / output_filename else: # File path with extension output_path_obj.parent.mkdir(parents=True, exist_ok=True) output_path_full = output_path_obj # Save the refined panel cv2.imwrite(str(output_path_full), self.refined_panel) print(f"Saved refined panel to: {output_path_full}") # Show visualization if requested if visualize: self.visualize() return self.refined_panel def visualize(self): """Visualize the extraction process in a 3x3 grid.""" if plt is None: print("matplotlib is not installed, skipping visualization.") return if self.original_panel is None: print("No panel to visualize.") return # Prepare figure with a 3x3 grid fig, axes = plt.subplots(3, 3, figsize=(12, 12)) # 1. First row: Original Panel, HSV Image, Color Mask stages = [ ("Original Panel", self.original_panel, cv2.COLOR_BGR2RGB), ("HSV Image", self.hsv_image, cv2.COLOR_HSV2RGB), ("Color Mask", self.color_mask, None) # Grayscale ] for i, (title, img, color_conversion) in enumerate(stages): ax = axes[0, i] if img is None: ax.text(0.5, 0.5, "Not Available", ha="center", va="center") elif len(img.shape) == 2: # Grayscale ax.imshow(img, cmap="gray") else: # Color ax.imshow(cv2.cvtColor(img, color_conversion) if color_conversion else img) ax.set_title(title) ax.axis("off") # 2. Second row: Individual HSV Channels if self.hsv_image is not None: h, s, v = cv2.split(self.hsv_image) channels = [("Hue", h), ("Saturation", s), ("Value", v)] for i, (title, channel) in enumerate(channels): ax = axes[1, i] ax.imshow(channel, cmap="gray") ax.set_title(title) ax.axis("off") # 3. Third row: Significant Contours, Content Boundary, Refined Panel if self.color_mask is not None: ax = axes[2, 0] debug_img = self.original_panel.copy() contours, _ = cv2.findContours(self.color_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) img_area = self.original_panel.shape[0] * self.original_panel.shape[1] min_area = img_area * self.min_content_area significant_contours = [c for c in contours if cv2.contourArea(c) > min_area] cv2.drawContours(debug_img, significant_contours, -1, (0, 255, 0), 2) ax.imshow(cv2.cvtColor(debug_img, cv2.COLOR_BGR2RGB)) ax.set_title("Significant Contours") ax.axis("off") if self.content_box is not None: ax = axes[2, 1] debug_img = self.original_panel.copy() x, y, w, h = self.content_box cv2.rectangle(debug_img, (x, y), (x+w, y+h), (0, 255, 0), 2) ax.imshow(cv2.cvtColor(debug_img, cv2.COLOR_BGR2RGB)) ax.set_title("Content Boundary") ax.axis("off") if self.refined_panel is not None: ax = axes[2, 2] ax.imshow(cv2.cvtColor(self.refined_panel, cv2.COLOR_BGR2RGB)) ax.set_title("Refined Panel") ax.axis("off") # Adjust layout plt.tight_layout() plt.show() def process_panel_with_color(image_path, output_path=None, visualize=False, saturation_threshold=20, value_range=(30, 225), min_content_area=0.1, padding=5): """ Process a manga/manhwa panel to extract colored content. Args: image_path (str): Path to the input panel image output_path (str, optional): Path to save the refined panel visualize (bool): Whether to show visualization saturation_threshold (int): Minimum saturation to consider as colored value_range (tuple): Range of brightness values to consider (min, max) min_content_area (float): Minimum area ratio to consider as valid content padding (int): Padding to add around detected content Returns: numpy.ndarray: The refined panel image """ print(f"\n==== PROCESSING PANEL: {image_path} ====") # Create the color extractor extractor = PanelColorExtractor( saturation_threshold=saturation_threshold, value_range=value_range, min_content_area=min_content_area, padding=padding ) # Process the panel try: refined_panel = extractor.process_panel(image_path, output_path, visualize) print(f"==== COMPLETED PROCESSING PANEL: {image_path} ====\n") return refined_panel except Exception as e: import traceback print(f"ERROR during processing: {e}") traceback.print_exc() return None # Example usage if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Manga Panel Color Extractor") parser.add_argument("input_path", help="Path to input panel image") parser.add_argument("--output", help="Path to save refined panel") parser.add_argument("--visualize", action="store_true", help="Show visualization") parser.add_argument("--saturation", type=int, default=20, help="Minimum saturation to consider as colored") parser.add_argument("--value-min", type=int, default=30, help="Minimum brightness value") parser.add_argument("--value-max", type=int, default=225, help="Maximum brightness value") parser.add_argument("--min-area", type=float, default=0.1, help="Minimum content area ratio") parser.add_argument("--padding", type=int, default=5, help="Padding around content boundaries") args = parser.parse_args() process_panel_with_color( args.input_path, args.output, args.visualize, args.saturation, (args.value_min, args.value_max), args.min_area, args.padding )