diff --git a/MangaPanelExtractor.py b/MangaPanelExtractor.py new file mode 100644 index 0000000..25e7498 --- /dev/null +++ b/MangaPanelExtractor.py @@ -0,0 +1,615 @@ +import itertools + +import cv2 +import numpy as np +from typing import List, Tuple, Dict +import matplotlib.pyplot as plt +from shapely.geometry import Polygon +from shapely.validation import make_valid + + +class MangaPanelExtractor: + def __init__(self, threshold_value: int = 240, min_rel_panel_area: float = 0.01, + expansion_pixels: int = 50, max_vertices: int = 6): + """ + Initialize the manga panel extractor. + + Args: + threshold_value: Threshold for converting to binary (higher = more selective for white) + min_rel_panel_area: Minimum panel area as percentage of total page area (0.01 = 1%) + expansion_pixels: Pixels to expand image borders for edge panel detection + max_vertices: Maximum vertices allowed in simplified polygon (default 6) + """ + self.threshold_value = threshold_value + self.min_rel_panel_area = min_rel_panel_area + self.expansion_pixels = expansion_pixels + self.max_vertices = max_vertices + self.min_panel_area = None # Will be computed from image size + + def find_empty_corner(self, binary_img: np.ndarray, corner_size: int = 50) -> Tuple[int, int]: + """ + Find an empty corner (white area) to start flood fill. + Checks all four corners and returns coordinates of the emptiest one. + """ + h, w = binary_img.shape + corners = [ + (0, 0), # Top-left + (0, w - 1), # Top-right + (h - 1, 0), # Bottom-left + (h - 1, w - 1) # Bottom-right + ] + + best_corner = corners[0] + max_white_pixels = 0 + + for corner in corners: + y, x = corner + # Define sample region around corner + y_start = max(0, y - corner_size // 2) + y_end = min(h, y + corner_size // 2) + x_start = max(0, x - corner_size // 2) + x_end = min(w, x + corner_size // 2) + + sample_region = binary_img[y_start:y_end, x_start:x_end] + white_pixels = np.sum(sample_region == 255) + + if white_pixels > max_white_pixels: + max_white_pixels = white_pixels + best_corner = corner + + return best_corner + + def expand_image(self, img: np.ndarray, pixels: int) -> Tuple[np.ndarray, Tuple[int, int]]: + """ + Expand image by adding white border around it. + + Returns: + Expanded image and offset coordinates (for coordinate correction later) + """ + if len(img.shape) == 3: # Color image + expanded = cv2.copyMakeBorder( + img, pixels, pixels, pixels, pixels, + cv2.BORDER_CONSTANT, value=[255, 255, 255] + ) + else: # Grayscale + expanded = cv2.copyMakeBorder( + img, pixels, pixels, pixels, pixels, + cv2.BORDER_CONSTANT, value=255 + ) + + return expanded, (pixels, pixels) + + def simplify_contour(self, contour: np.ndarray, max_vertices: int = None) -> np.ndarray: + """ + Simplify contour to reduce vertices, preferring rectangular shapes. + """ + if max_vertices is None: + max_vertices = self.max_vertices + + # Calculate contour perimeter for epsilon calculation + perimeter = cv2.arcLength(contour, True) + + # Start with a small epsilon and gradually increase until we get desired vertex count + epsilon_factor = 0.01 + simplified = contour + + while len(simplified) > max_vertices and epsilon_factor < 0.1: + epsilon = epsilon_factor * perimeter + simplified = cv2.approxPolyDP(contour, epsilon, True) + epsilon_factor += 0.005 + + # If still too many vertices, try more aggressive simplification + if len(simplified) > max_vertices: + epsilon = 0.1 * perimeter + simplified = cv2.approxPolyDP(contour, epsilon, True) + + return simplified + + def contour_to_shapely_polygon(self, contour: np.ndarray, offset: Tuple[int, int] = (0, 0)) -> Polygon: + """ + Convert OpenCV contour to Shapely polygon, adjusting for image expansion offset. + """ + # Adjust coordinates back to original image space + offset_x, offset_y = offset + points = [] + + for point in contour: + x, y = point[0] + # Subtract offset to get original coordinates + original_x = x - offset_x + original_y = y - offset_y + points.append((original_x, original_y)) + + # Create polygon and ensure it's valid + try: + polygon = Polygon(points) + if not polygon.is_valid: + polygon = make_valid(polygon) + return polygon + except Exception: + # Fallback: create a simple polygon from bounding box + x_coords = [p[0] for p in points] + y_coords = [p[1] for p in points] + min_x, max_x = min(x_coords), max(x_coords) + min_y, max_y = min(y_coords), max(y_coords) + return Polygon([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]) + + def calculate_internal_angle(self, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> float: + """ + Calculate the internal angle at point p2 formed by p1-p2-p3. + Returns angle in degrees. + """ + # Create vectors + v1 = p1 - p2 + v2 = p3 - p2 + + # Calculate angle using dot product + cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + cos_angle = np.clip(cos_angle, -1, 1) # Handle floating point errors + angle_rad = np.arccos(cos_angle) + angle_deg = np.degrees(angle_rad) + + return angle_deg + + def score_vertex_for_removal(self, contour: np.ndarray, vertex_idx: int) -> Tuple[float, float]: + """ + Score a vertex for removal based on internal angle and area gain. + + Returns: + (angle_score, area_score) where higher scores = better candidates for removal + """ + n_points = len(contour) + if n_points <= 3: # Can't remove from triangle or smaller + return 0.0, 0.0 + + # Get the three points for angle calculation + prev_idx = (vertex_idx - 1) % n_points + next_idx = (vertex_idx + 1) % n_points + + p1 = contour[prev_idx][0] + p2 = contour[vertex_idx][0] + p3 = contour[next_idx][0] + + # Calculate internal angle + internal_angle = self.calculate_internal_angle(p1, p2, p3) + + # Angle score: higher for angles close to 180° (straight lines/jagged cuts) + # Peak score at 180°, lower scores for acute angles + angle_score = 1.0 - abs(internal_angle - 180.0) / 180.0 + + # Calculate area gain by removing this vertex + original_area = cv2.contourArea(contour) + + # Create new contour without this vertex + new_contour = np.delete(contour, vertex_idx, axis=0) + + if len(new_contour) >= 3: + new_area = cv2.contourArea(new_contour) + area_gain = new_area - original_area + # Normalize area score (higher is better for removal) + area_score = max(0, area_gain / original_area) if original_area > 0 else 0 + else: + area_score = 0.0 + + return angle_score, area_score + + def convert_nonconvex_to_convex(self, contour: np.ndarray, max_iterations: int = 10) -> np.ndarray: + """ + Attempt to convert a non-convex shape to convex by removing problematic vertices. + + Args: + contour: OpenCV contour + max_iterations: Maximum number of vertices to remove + + Returns: + Modified contour (convex if successful) + """ + current_contour = contour.copy() + + for iteration in range(max_iterations): + # Check if already convex + if self.is_convex(current_contour): + break + + # Need at least 4 points to continue removing + if len(current_contour) <= 3: + break + + # Score all vertices for removal + vertex_scores = [] + for i in range(len(current_contour)): + angle_score, area_score = self.score_vertex_for_removal(current_contour, i) + + # Combined score: prioritize high internal angles, then area gain + combined_score = angle_score * 2.0 + area_score * 1.0 + vertex_scores.append((i, combined_score, angle_score, area_score)) + + # Sort by combined score (highest first) + vertex_scores.sort(key=lambda x: x[1], reverse=True) + + # Remove the vertex with highest score + if vertex_scores and vertex_scores[0][1] > 0: + best_vertex_idx = vertex_scores[0][0] + current_contour = np.delete(current_contour, best_vertex_idx, axis=0) + else: + # No good candidates for removal + break + + return current_contour + + def analyze_nonconvex_recovery(self, results: Dict) -> Dict: + """ + Attempt to recover panels from non-convex shapes using vertex removal. + + Returns: + Updated results with recovered panels + """ + recovered_panels = [] + remaining_nonconvex = [] + + for shape in results['non_convex_shapes']: + original_contour = shape['contour'] + + # Attempt to make it convex + recovered_contour = self.convert_nonconvex_to_convex(original_contour) + + # Check if we successfully made it convex and it's still a reasonable size + if (self.is_convex(recovered_contour) and + cv2.contourArea(recovered_contour) > self.min_panel_area * 0.5): # Allow smaller after recovery + + # Convert to polygon and analyze + polygon = self.contour_to_shapely_polygon(recovered_contour, results['offset']) + shape_analysis = self.analyze_panel_shape(polygon) + + recovered_panels.append({ + 'contour': recovered_contour, + 'original_contour': original_contour, + 'polygon': polygon, + 'area': cv2.contourArea(recovered_contour), + 'bbox': cv2.boundingRect(recovered_contour), + 'shape_analysis': shape_analysis, + 'type': 'recovered_panel', + 'recovery_info': { + 'original_vertices': len(original_contour), + 'final_vertices': len(recovered_contour), + 'vertices_removed': len(original_contour) - len(recovered_contour) + } + }) + else: + # Keep as non-convex + remaining_nonconvex.append(shape) + + # Update results + results['recovered_panels'] = recovered_panels + results['non_convex_shapes'] = remaining_nonconvex + results['panels'].extend(recovered_panels) # Add to main panels list + + return results + + def analyze_panel_shape(self, polygon: Polygon) -> Dict: + """ + Analyze the shape characteristics of a panel polygon. + """ + coords = list(polygon.exterior.coords[:-1]) # Remove duplicate last point + num_vertices = len(coords) + + # Calculate aspect ratio from bounding box + bounds = polygon.bounds # (minx, miny, maxx, maxy) + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + aspect_ratio = width / height if height > 0 else 1.0 + + # Determine likely panel type + if num_vertices == 4: + panel_type = "rectangular" + elif num_vertices == 3: + panel_type = "triangular" + elif num_vertices <= 6: + panel_type = f"{num_vertices}-sided" + else: + panel_type = "complex" + + return { + 'vertices': num_vertices, + 'aspect_ratio': aspect_ratio, + 'panel_type': panel_type, + 'area': polygon.area, + 'bounds': bounds + } + """ + Find an empty corner (white area) to start flood fill. + Checks all four corners and returns coordinates of the emptiest one. + """ + h, w = binary_img.shape + corners = [ + (0, 0), # Top-left + (0, w - 1), # Top-right + (h - 1, 0), # Bottom-left + (h - 1, w - 1) # Bottom-right + ] + + best_corner = corners[0] + max_white_pixels = 0 + + for corner in corners: + y, x = corner + # Define sample region around corner + y_start = max(0, y - corner_size // 2) + y_end = min(h, y + corner_size // 2) + x_start = max(0, x - corner_size // 2) + x_end = min(w, x + corner_size // 2) + + sample_region = binary_img[y_start:y_end, x_start:x_end] + white_pixels = np.sum(sample_region == 255) + + if white_pixels > max_white_pixels: + max_white_pixels = white_pixels + best_corner = corner + + return best_corner + + def is_convex(self, contour: np.ndarray) -> bool: + """ + Check if a contour represents a convex shape. + """ + hull = cv2.convexHull(contour) + hull_area = cv2.contourArea(hull) + contour_area = cv2.contourArea(contour) + + if hull_area == 0: + return False + + # If the ratio is close to 1, the shape is convex + convexity_ratio = contour_area / hull_area + return convexity_ratio > 0.85 # Allow some tolerance + + def extract_panels(self, img: np.ndarray) -> Dict: + """ + Extract panels from manga page using refined flood fill approach. + + :param img: openCV ready, in BGR format, image. + :return: Dictionary containing panels, non-convex shapes, and debug images + """ + # Load and preprocess image + # img = cv2.imread(image_path) + #if img is None: + # raise ValueError(f"Could not load image: {image_path}") + # (image[0].cpu().numpy()[..., ::-1] * 255).astype(np.uint8) + + # Store original dimensions for reference + original_shape = img.shape + page_area = original_shape[0] * original_shape[1] # height * width + self.min_panel_area = page_area * self.min_rel_panel_area + + # Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Expand image to detect edge panels + expanded_img, offset = self.expand_image(img, self.expansion_pixels) + expanded_gray, _ = self.expand_image(gray, self.expansion_pixels) + + # Create binary image (white background, black content) + _, binary = cv2.threshold(expanded_gray, self.threshold_value, 255, cv2.THRESH_BINARY) + + # Find empty corner for flood fill (now in expanded image) + start_point = self.find_empty_corner(binary) + start_point = list(start_point)[::-1] # swap x and y for floodfill =/ + + # Create flood fill mask + h, w = binary.shape + mask = np.zeros((h + 2, w + 2), np.uint8) + + # Perform flood fill from empty corner + flood_filled = binary.copy() + cv2.floodFill(flood_filled, mask, start_point, 128) # Fill with gray (128) + + # Create mask of non-filled areas (panels and content) + panel_mask = (flood_filled != 128).astype(np.uint8) * 255 + + # Find contours in the panel mask + contours, _ = cv2.findContours(panel_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Classify and process contours + panels = [] + non_convex_shapes = [] + + for contour in contours: + area = cv2.contourArea(contour) + + # Filter out very small areas (noise) + if area < self.min_panel_area: + continue + + # Simplify contour + simplified_contour = self.simplify_contour(contour) + + # Convert to Shapely polygon + polygon = self.contour_to_shapely_polygon(simplified_contour, offset) + + # Analyze shape + shape_analysis = self.analyze_panel_shape(polygon) + + # Check if convex and classify + if self.is_convex(simplified_contour): + panels.append({ + 'contour': simplified_contour, + 'original_contour': contour, + 'polygon': polygon, + 'area': area, + 'bbox': cv2.boundingRect(simplified_contour), + 'shape_analysis': shape_analysis, + 'type': 'panel' + }) + else: + non_convex_shapes.append({ + 'contour': simplified_contour, + 'original_contour': contour, + 'polygon': polygon, + 'area': area, + 'bbox': cv2.boundingRect(simplified_contour), + 'shape_analysis': shape_analysis, + 'type': 'non_convex' + }) + + return { + 'original': img, + 'expanded': expanded_img, + 'binary': binary, + 'flood_filled': flood_filled, + 'panel_mask': panel_mask, + 'panels': panels, + 'non_convex_shapes': non_convex_shapes, + 'recovered_panels': [], # Will be populated if recovery is run + 'start_point': start_point, + 'offset': offset, + 'original_shape': original_shape + } + + # optional user "enforced" panel shape related funcs + + def simplify_panels(self, results: Dict, method: str) -> Dict: + for i, r in enumerate(results["panels"]): + results["panels"][i]["polygon"] = \ + MangaPanelExtractor.simplify_polygon_to_rectangle(results["panels"][i]["polygon"], method) + return results + + @staticmethod + def simplify_polygon_to_rectangle(polygon: Polygon, method: str = 'max_area_combination') -> Polygon: + """ + Simplify a polygon with >4 vertices to exactly 4 vertices (rectangle) by + maximizing the area of the resulting shape. + + Args: + polygon: Shapely polygon with >4 vertices + method: 'max_area_combination' or 'bounding_box' + + Returns: + Simplified 4-vertex polygon with maximum possible area + """ + coords = list(polygon.exterior.coords[:-1]) # Remove duplicate last point + + if method == 'bounding_box': + return MangaPanelExtractor._simplify_to_bounding_box(polygon) + + if len(coords) <= 4: + return polygon + + return MangaPanelExtractor.simplify_maximize_area(polygon) + + @staticmethod + def simplify_maximize_area(polygon: Polygon) -> Polygon: + """ + Simplify polygon to 4 vertices by finding the combination that maximizes area. + """ + coords, original_area = polygon.exterior.coords, polygon.area + + n_vertices = len(coords) + + if n_vertices <= 4: + return Polygon(coords) + + # For very large polygons, use a heuristic approach to avoid combinatorial explosion + if n_vertices > 12: + return MangaPanelExtractor._simplify_large_polygon_max_area_heuristic(coords) + + # Try all combinations of 4 vertices from the original polygon + max_area = 0 + best_combination = None + + for combination in itertools.combinations(range(n_vertices), 4): + # Extract the 4 vertices + selected_coords = [coords[i] for i in combination] + + # Ensure proper ordering (clockwise or counter-clockwise) + ordered_coords = MangaPanelExtractor._order_vertices_properly(selected_coords) + + try: + candidate_polygon = Polygon(ordered_coords) + if not candidate_polygon.is_valid: + candidate_polygon = make_valid(candidate_polygon) + + # We want to maximize area + candidate_area = candidate_polygon.area + + if candidate_area > max_area: + max_area = candidate_area + best_combination = ordered_coords + + except Exception: + continue # Skip invalid combinations + + if best_combination is None: + # Fallback to bounding box (guaranteed to be large) + return MangaPanelExtractor._simplify_to_bounding_box(Polygon(coords)) + + return Polygon(best_combination) + + @staticmethod + def _order_vertices_properly(vertices: List[Tuple[float, float]]) -> List[Tuple[float, float]]: + """ + Order vertices in proper sequence (clockwise or counter-clockwise) for a valid polygon. + """ + # Calculate centroid + centroid_x = sum(v[0] for v in vertices) / len(vertices) + centroid_y = sum(v[1] for v in vertices) / len(vertices) + + # Calculate angle from centroid to each vertex + def angle_from_centroid(vertex): + return np.arctan2(vertex[1] - centroid_y, vertex[0] - centroid_x) + + # Sort vertices by angle + sorted_vertices = sorted(vertices, key=angle_from_centroid) + + return sorted_vertices + + @staticmethod + def _simplify_to_bounding_box(polygon: Polygon) -> Polygon: + """Convert polygon to its bounding box rectangle.""" + bounds = polygon.bounds # (minx, miny, maxx, maxy) + minx, miny, maxx, maxy = bounds + + rectangle_coords = [ + (minx, miny), + (maxx, miny), + (maxx, maxy), + (minx, maxy) + ] + + return Polygon(rectangle_coords) + + @staticmethod + def _simplify_large_polygon_heuristic(coords: List[Tuple[float, float]], original_area: float) -> Polygon: + """ + Heuristic approach for polygons with high vertex count (>12). + Selects vertices that are most important for maintaining shape. + """ + n = len(coords) + + # Calculate importance score for each vertex + vertex_scores = [] + + for i in range(n): + prev_idx = (i - 1) % n + next_idx = (i + 1) % n + + # Calculate the area of triangle formed by this vertex and its neighbors + p1, p2, p3 = coords[prev_idx], coords[i], coords[next_idx] + triangle_area = abs((p2[0] - p1[0]) * (p3[1] - p1[1]) - (p3[0] - p1[0]) * (p2[1] - p1[1])) / 2 + + # Calculate distance from centroid (corner vertices are typically further) + centroid = (sum(c[0] for c in coords) / n, sum(c[1] for c in coords) / n) + distance_from_centroid = ((coords[i][0] - centroid[0]) ** 2 + (coords[i][1] - centroid[1]) ** 2) ** 0.5 + + # Combined importance score + importance = triangle_area * 0.7 + distance_from_centroid * 0.3 + vertex_scores.append((i, importance)) + + # Sort by importance and take top 4 + vertex_scores.sort(key=lambda x: x[1], reverse=True) + top_4_indices = [idx for idx, _ in vertex_scores[:4]] + top_4_indices.sort() # Maintain original ordering + + selected_coords = [coords[i] for i in top_4_indices] + ordered_coords = MangaPanelExtractor._order_vertices_properly(selected_coords) + + return Polygon(ordered_coords) diff --git a/nodes.py b/nodes.py index d66d190..bfa0402 100644 --- a/nodes.py +++ b/nodes.py @@ -1321,6 +1321,64 @@ class CropImageByBBox: # endregion Other Nodes +from .MangaPanelExtractor import MangaPanelExtractor + + +class DetectPanelsInImage: + + SIMPLIFICATION_METHODS = ["none", "bounding_box", "max_area_combination"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "threshold": ("INT", {"default": 240, "min": 1, "max": 254, "tooltip": + "binary threshold when analysing the image"}), + "min_rel_area": ("FLOAT", {"default": .025, "min": .001, "max": .999, "step": .001, "tooltip": + "contours with an area percentage with respect to the image size inferior to this value are discarded"}), + "simplify": (cls.SIMPLIFICATION_METHODS, {"default": cls.SIMPLIFICATION_METHODS[0], "tooltip": + "Simplify the final polygons according to the provided criteria."}), + + # the below args are subject to being discarded/omitted in the future + "max_vertices": ("INT", {"default": 6, "min": 3, "max": 9, "tooltip": + "Simplify contour shapes with higher vertex count when analysing the contours.\n" + "For most use cases use the default value."}), + "recover_non_convex": ("BOOLEAN", {"default": True, "tooltip": + "If set to False, discards non convex shapes when detecting the contours.\n" + "For most use cases use the default value."}) + }, + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ("Panels (Shapely Polygons)",) + FUNCTION = "func" + DESCRIPTION = ("'Simple' CV algo to generate panel layout from an image.\n" + "You can build a custom layout using an image as input to this node.\n" + "Paint the background white and the panels black.\n" + "It can also be used directly over simple comic or manga pages, " + "whose panel delimitation is very explicit.") + + def func(self, image, threshold, min_rel_area, simplify, max_vertices, recover_non_convex): + cv_img = (image[0].cpu().numpy()[..., ::-1] * 255).astype(np.uint8) + extractor = MangaPanelExtractor( + threshold_value=threshold, + min_rel_panel_area=min_rel_area, + expansion_pixels=50, + max_vertices=max_vertices + ) + results = extractor.extract_panels(cv_img) + if recover_non_convex and results['non_convex_shapes']: + print(f"Attempting to recover {len(results['non_convex_shapes'])} non-convex shapes...") + results = extractor.analyze_nonconvex_recovery(results) + if simplify != "none": + extractor.simplify_panels(results, simplify) + panels = [panel["polygon"] for panel in results["panels"]] + return (panels,) + + NODE_CLASS_MAPPINGS = { "bmad_CanvasPanel": CanvasPanel, "bmad_LoadPanelLayout": LoadPanelLayout, @@ -1347,6 +1405,7 @@ NODE_CLASS_MAPPINGS = { "bmad_RandomPanelLayoutGenerator": RandomPanelLayoutGenerator, "bmad_GridPanelLayoutGenerator": GridPanelLayoutGenerator, "bmad_MutatePanelLayout": MutatePanelLayout, + "bmad_DetectPanelsInImage" : DetectPanelsInImage, "bmad_PolygonBounds": PolygonBounds, "bmad_PolygonUnwrappedBounds": PolygonUnwrappedBounds, @@ -1392,6 +1451,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "bmad_RandomPanelLayoutGenerator": "Random Panel Layout Generator", "bmad_GridPanelLayoutGenerator": "Grid Panel Layout Generator", "bmad_MutatePanelLayout": "Mutate Panel Layout", + "bmad_DetectPanelsInImage": "Detect Panels In Image", "bmad_PolygonBounds": "Polygon.bounds", "bmad_PolygonUnwrappedBounds": "Polygon.bounds (unwrapped)", diff --git a/pyproject.toml b/pyproject.toml index 3d2fff6..c99a219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "comfyui_panels" description = "Comics/Manga like panel layouts." version = "1.0.0" license = { file = "LICENSE" } -dependencies = ["shapely==2.1.1", "matplotlib==3.10.6"] +dependencies = ["shapely==2.1.1", "matplotlib==3.10.6", "opencv-python~=4.8.1.78"] [project.urls] Repository = "https://github.com/bmad4ever/comfyui-panels" diff --git a/requirements.txt b/requirements.txt index da297a7..8484342 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ shapely==2.1.1 -matplotlib==3.10.6 \ No newline at end of file +matplotlib==3.10.6 +opencv-python~=4.8.1.78 \ No newline at end of file diff --git a/workflows/Custom Panel Layout from Mask like Image.png b/workflows/Custom Panel Layout from Mask like Image.png new file mode 100644 index 0000000..aeeea74 Binary files /dev/null and b/workflows/Custom Panel Layout from Mask like Image.png differ diff --git a/workflows/Manga Panel Extration Example A.png b/workflows/Manga Panel Extration Example A.png new file mode 100644 index 0000000..1ba3446 Binary files /dev/null and b/workflows/Manga Panel Extration Example A.png differ diff --git a/workflows/Manga Panel Extration Example B.png b/workflows/Manga Panel Extration Example B.png new file mode 100644 index 0000000..d419bca Binary files /dev/null and b/workflows/Manga Panel Extration Example B.png differ diff --git a/workflows/Manga Panel Extration Example C.png b/workflows/Manga Panel Extration Example C.png new file mode 100644 index 0000000..de74713 Binary files /dev/null and b/workflows/Manga Panel Extration Example C.png differ