jebin2 commited on
Commit
79125e3
·
1 Parent(s): bd1f76d

new change

Browse files
comic_panel_extractor/border_panel_extractor.py ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import numpy as np
4
+ from PIL import Image, ImageDraw
5
+ import imageio.v2 as imageio
6
+ from skimage.color import rgb2gray
7
+ from skimage.feature import canny
8
+ from skimage import measure
9
+ from scipy import ndimage as ndi
10
+ from skimage.morphology import remove_small_holes
11
+ import cv2
12
+
13
+ from .config import Config
14
+ from .image_processor import ImageProcessor
15
+ from .utils import remove_duplicate_boxes
16
+
17
+ class BorderPanelExtractor:
18
+ """
19
+ Handles image preprocessing operations for extracting comic/manga panels.
20
+
21
+ This class provides functionality to:
22
+ - Create segmentation masks from images
23
+ - Extract white panels from segmented images
24
+ - Remove panels from original images
25
+ - Merge nearby panels
26
+ """
27
+
28
+ def __init__(self, config: Config = None):
29
+ """Initialize the BorderPanelExtractor with optional configuration."""
30
+ self.config = config or Config()
31
+ self.output_folder = f'{self.config.output_folder}/border_panel_extractor'
32
+ os.makedirs(self.output_folder, exist_ok=True)
33
+ self.PANEL_FILENAME_PATTERN = re.compile(self.config.panel_filename_pattern)
34
+
35
+ def create_segmentation_mask(self, image: np.ndarray) -> np.ndarray:
36
+ """
37
+ Create segmentation mask from image using edge detection and hole filling.
38
+
39
+ Args:
40
+ image: Input RGB image as numpy array
41
+
42
+ Returns:
43
+ Binary segmentation mask as numpy array
44
+ """
45
+ Image.fromarray(image).save(f"{self.output_folder}/00_original.jpg")
46
+
47
+ # Convert to grayscale and detect edges
48
+ grayscale = rgb2gray(image)
49
+ edges = canny(grayscale)
50
+
51
+ self._save_debug_image(grayscale, f"{self.output_folder}/01_grayscale.jpg")
52
+ self._save_debug_image(edges, f"{self.output_folder}/02_edges.jpg")
53
+
54
+ # Process edges with morphological operations
55
+ segmentation = self._process_edges_for_segmentation(edges)
56
+
57
+ # Check if additional processing is needed
58
+ if self._needs_edge_fallback(segmentation):
59
+ print("⚠️ White ratio too high, reverting to basic edge filling")
60
+ segmentation = ndi.binary_fill_holes(edges)
61
+
62
+ # Clean up small holes
63
+ segmentation_cleaned = remove_small_holes(
64
+ segmentation,
65
+ area_threshold=500
66
+ )
67
+
68
+ segmentation_filled_path = f"{self.output_folder}/03_segmentation_filled.jpg"
69
+ self._save_debug_image(
70
+ segmentation_cleaned,
71
+ segmentation_filled_path
72
+ )
73
+
74
+ return segmentation_cleaned, segmentation_filled_path
75
+
76
+ def extract_fully_white_panels(
77
+ self,
78
+ original_image: np.ndarray,
79
+ segmentation_mask: np.ndarray
80
+ ):
81
+ """
82
+ Extract fully white panels from a segmented image.
83
+
84
+ Args:
85
+ original_image: Original RGB image as numpy array
86
+ segmentation_mask: Binary segmentation mask
87
+
88
+ Returns:
89
+ List of saved panel file paths
90
+ """
91
+ # Get image dimensions and prepare data
92
+ img_h, img_w = segmentation_mask.shape
93
+ image_area = img_h * img_w
94
+ orig_pil = Image.fromarray(original_image)
95
+
96
+ # Find and process regions
97
+ labeled_mask = measure.label(segmentation_mask)
98
+ regions = measure.regionprops(labeled_mask)
99
+
100
+ accepted_boxes = []
101
+
102
+ for idx, region in enumerate(regions):
103
+ # Extract region properties
104
+ minr, minc, maxr, maxc = region.bbox
105
+ w, h = maxc - minc, maxr - minr
106
+ area = w * h
107
+
108
+ # Check size thresholds
109
+ if self._meets_size_requirements(area, w, h, image_area, img_w, img_h):
110
+ continue
111
+
112
+ # Check if region is mostly white
113
+ if not self._is_mostly_white_region(region, idx):
114
+ continue
115
+
116
+ # Save valid panel
117
+ accepted_boxes.append((minc, minr, maxc, maxr))
118
+
119
+ self._create_visualization(orig_pil, accepted_boxes, "extract_fully_white_panels.jpg")
120
+
121
+ return accepted_boxes
122
+
123
+ def extract_with_contours(
124
+ self,
125
+ original_image: np.ndarray,
126
+ segmentation_mask_path: str
127
+ ):
128
+ img = cv2.imread(segmentation_mask_path)
129
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
130
+
131
+ # Threshold to get binary image
132
+ _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
133
+
134
+ # Find contours
135
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
136
+
137
+ accepted_boxes = []
138
+ # Draw bounding rectangles
139
+ img_h, img_w = original_image.shape[:2]
140
+ image_area = img_h * img_w
141
+ max_ratio = 0.7 # Max box area must be less than 70% of image
142
+ for cnt in contours:
143
+ x, y, w, h = cv2.boundingRect(cnt)
144
+ box_area = w * h
145
+ if box_area / image_area < max_ratio:
146
+ minc, minr = x, y
147
+ maxc, maxr = x + w, y + h
148
+ accepted_boxes.append((minc, minr, maxc, maxr))
149
+
150
+ orig_pil = Image.fromarray(original_image)
151
+ self._create_visualization(orig_pil, accepted_boxes, "extract_with_contours.jpg")
152
+
153
+ return accepted_boxes
154
+
155
+ def remove_duplicate_boxes(self, boxes, iou_threshold=0.7):
156
+ """
157
+ Removes duplicate or highly overlapping boxes, keeping the larger one.
158
+ :param boxes: List of (x1, y1, x2, y2) boxes.
159
+ :param iou_threshold: Threshold above which boxes are considered duplicates.
160
+ :return: List of unique boxes.
161
+ """
162
+ def compute_iou(boxA, boxB):
163
+ xA = max(boxA[0], boxB[0])
164
+ yA = max(boxA[1], boxB[1])
165
+ xB = min(boxA[2], boxB[2])
166
+ yB = min(boxA[3], boxB[3])
167
+
168
+ interArea = max(0, xB - xA) * max(0, yB - yA)
169
+ if interArea == 0:
170
+ return 0.0
171
+
172
+ boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
173
+ boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
174
+ iou = interArea / float(boxAArea + boxBArea - interArea)
175
+ return iou
176
+
177
+ def compute_area(box):
178
+ return (box[2] - box[0]) * (box[3] - box[1])
179
+
180
+ unique_boxes = []
181
+ for box in boxes:
182
+ box_area = compute_area(box)
183
+ replaced_existing = False
184
+
185
+ # Check against existing unique boxes
186
+ for i, ubox in enumerate(unique_boxes):
187
+ if compute_iou(box, ubox) > iou_threshold:
188
+ ubox_area = compute_area(ubox)
189
+ # If current box is larger, replace the existing one
190
+ if box_area > ubox_area:
191
+ unique_boxes[i] = box
192
+ replaced_existing = True
193
+ # If existing box is larger or equal, ignore current box
194
+ break
195
+
196
+ # If no overlap found, add the box
197
+ if not replaced_existing and not any(compute_iou(box, ubox) > iou_threshold for ubox in unique_boxes):
198
+ unique_boxes.append(box)
199
+
200
+ print(f"✅ Found {abs(len(unique_boxes) - len(boxes))} duplicates")
201
+ return unique_boxes
202
+
203
+ def extend_boxes_to_image_border(self, boxes, image_shape):
204
+ """
205
+ Extends any side of a bounding box to the image border if it's close enough.
206
+
207
+ :param boxes: List of (x1, y1, x2, y2) tuples.
208
+ :param image_shape: (height, width) of the image.
209
+ :param threshold: Pixel threshold to snap to border.
210
+ :return: List of adjusted boxes.
211
+ """
212
+ if not boxes:
213
+ return boxes
214
+ extended_boxes = [list(box) for box in boxes]
215
+ height, width = image_shape[:2]
216
+ adjusted_boxes = []
217
+
218
+ width_threshold = min(x2 - x1 for x1, y1, x2, y2 in extended_boxes)
219
+ height_threshold = min(y2 - y1 for x1, y1, x2, y2 in extended_boxes)
220
+
221
+ # width_threshold = self.config.min_width_ratio * width
222
+ # height_threshold = self.config.min_height_ratio * height
223
+
224
+ percent_threshold=0.8
225
+ for x1, y1, x2, y2 in boxes:
226
+ box_width = x2 - x1
227
+ box_height = y2 - y1
228
+
229
+ # Snap if close to left or top
230
+ if abs(x1 - 0) <= width_threshold or box_width >= percent_threshold * width:
231
+ x1 = 0
232
+ if abs(y1 - 0) <= height_threshold or box_height >= percent_threshold * height:
233
+ y1 = 0
234
+
235
+ # Snap if close to right or bottom
236
+ if abs(x2 - width) <= width_threshold or box_width >= percent_threshold * width:
237
+ x2 = width
238
+ if abs(y2 - height) <= height_threshold or box_height >= percent_threshold * height:
239
+ y2 = height
240
+ adjusted_boxes.append((x1, y1, x2, y2))
241
+
242
+ return adjusted_boxes
243
+
244
+ def create_image_with_panels_removed(
245
+ self,
246
+ original_image: np.ndarray,
247
+ segmentation_mask: np.ndarray,
248
+ segmentation_mask_path: str
249
+ ) -> None:
250
+ """
251
+ Create a version of the original image with detected panels blacked out.
252
+
253
+ Args:
254
+ original_image: Original RGB image as numpy array
255
+ segmentation_mask: Binary segmentation mask
256
+ output_path: Path to save the modified image
257
+ """
258
+ # Extract panels
259
+ accepted_boxes = self.extract_fully_white_panels(
260
+ original_image=original_image,
261
+ segmentation_mask=segmentation_mask
262
+ )
263
+
264
+ accepted_boxes.extend(
265
+ self.extract_with_contours(
266
+ original_image=original_image,
267
+ segmentation_mask_path=segmentation_mask_path
268
+ )
269
+ )
270
+
271
+ accepted_boxes = remove_duplicate_boxes(accepted_boxes)
272
+
273
+ accepted_boxes = self.threshold_based_filter(accepted_boxes, original_image.shape)
274
+
275
+ accepted_boxes = remove_duplicate_boxes(accepted_boxes)
276
+
277
+ accepted_boxes = self.extend_boxes_to_image_border(accepted_boxes, original_image.shape)
278
+
279
+ accepted_boxes = remove_duplicate_boxes(accepted_boxes)
280
+
281
+ accepted_boxes = sorted(accepted_boxes, key=lambda b: (b[1], b[0])) # sort by y1, then x1
282
+
283
+ accepted_boxes = self.extend_to_nearby_boxes(accepted_boxes, original_image.shape)
284
+
285
+ accepted_boxes = remove_duplicate_boxes(accepted_boxes)
286
+
287
+ all_paths = self._save_panel(original_image, accepted_boxes)
288
+
289
+ output_path = self.draw_black(original_image, accepted_boxes)
290
+
291
+ return all_paths, output_path
292
+
293
+ def draw_black(self, original_image, accepted_boxes) -> None:
294
+ orig_pil = Image.fromarray(original_image.copy())
295
+ draw = ImageDraw.Draw(orig_pil)
296
+
297
+ stripe_height = 10
298
+
299
+ for x1, y1, x2, y2 in accepted_boxes:
300
+ for y in range(y1, y2, stripe_height):
301
+ color = (0, 0, 0) if ((y - y1) // stripe_height) % 2 == 0 else (255, 255, 255)
302
+ y_end = min(y + stripe_height, y2)
303
+ draw.rectangle([x1, y, x2, y_end], fill=color)
304
+
305
+ # Save the result
306
+ output_path = os.path.join(self.config.output_folder, "00_original_with_panels_removed.jpg")
307
+ orig_pil.save(output_path)
308
+
309
+ return output_path
310
+
311
+ def get_black_white_ratio(self, image_path: str, threshold: int = 128) -> dict:
312
+ """
313
+ Calculate the ratio of black and white pixels in a binary image.
314
+
315
+ Args:
316
+ image_path: Path to the image file
317
+ threshold: Threshold value for binarization
318
+
319
+ Returns:
320
+ Dictionary with pixel ratios and counts
321
+ """
322
+ # Load and process image
323
+ img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
324
+ if img is None:
325
+ raise FileNotFoundError(f"Image not found: {image_path}")
326
+
327
+ # Convert to binary
328
+ _, binary = cv2.threshold(img, threshold, 255, cv2.THRESH_BINARY)
329
+
330
+ # Calculate ratios
331
+ total_pixels = binary.size
332
+ white_count = np.count_nonzero(binary == 255)
333
+ black_count = total_pixels - white_count
334
+
335
+ return {
336
+ "black_ratio": black_count / total_pixels,
337
+ "white_ratio": white_count / total_pixels,
338
+ "black_count": black_count,
339
+ "white_count": white_count,
340
+ "total_pixels": total_pixels
341
+ }
342
+
343
+ def get_region_count(self, binary_seg: np.ndarray) -> int:
344
+ """
345
+ Count valid regions in binary segmentation based on size thresholds.
346
+
347
+ Args:
348
+ binary_seg: Binary segmentation mask
349
+
350
+ Returns:
351
+ Number of valid regions
352
+ """
353
+ labeled_mask = measure.label(binary_seg)
354
+ regions = measure.regionprops(labeled_mask)
355
+
356
+ img_h, img_w = binary_seg.shape
357
+ image_area = img_h * img_w
358
+ count = 0
359
+
360
+ for region in regions:
361
+ minr, minc, maxr, maxc = region.bbox
362
+ w, h = maxc - minc, maxr - minr
363
+ area = w * h
364
+
365
+ if self._meets_size_requirements(area, w, h, image_area, img_w, img_h):
366
+ continue
367
+ count += 1
368
+
369
+ return count
370
+
371
+ def main(self) -> str:
372
+ """
373
+ Main execution function for panel extraction and removal.
374
+
375
+ Returns:
376
+ Path to the processed image with panels removed
377
+ """
378
+ # Load images
379
+ image = imageio.imread(self.config.input_path)
380
+ original_image = imageio.imread(self.config.input_path)
381
+
382
+ # Create initial segmentation mask
383
+ segmentation_mask, segmentation_mask_path = self.create_segmentation_mask(image)
384
+
385
+ # Check if additional processing is needed
386
+ pixel_ratios = self.get_black_white_ratio(segmentation_mask_path)
387
+
388
+ if pixel_ratios['black_ratio'] < 0.8:
389
+ print("✅ Black ratio is low, applying additional image processing")
390
+ segmentation_mask, segmentation_mask_path = self._apply_additional_processing(segmentation_mask_path)
391
+
392
+ # Create final output
393
+ all_paths, output_path = self.create_image_with_panels_removed(
394
+ original_image=original_image,
395
+ segmentation_mask=segmentation_mask,
396
+ segmentation_mask_path=segmentation_mask_path
397
+ )
398
+
399
+ return output_path
400
+
401
+ def _save_debug_image(self, image_array: np.ndarray, path: str) -> None:
402
+ """Save debug image with proper format conversion."""
403
+ if image_array.dtype == bool or image_array.max() <= 1:
404
+ image_uint8 = (image_array * 255).astype('uint8')
405
+ else:
406
+ image_uint8 = image_array.astype('uint8')
407
+ Image.fromarray(image_uint8).save(path)
408
+
409
+ def _process_edges_for_segmentation(self, edges: np.ndarray) -> np.ndarray:
410
+ """Process edges with morphological operations and fill holes."""
411
+ edges_uint8 = (edges * 255).astype('uint8')
412
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
413
+ seg = cv2.dilate(edges_uint8, kernel, iterations=2)
414
+ seg = cv2.ximgproc.thinning(seg)
415
+ return ndi.binary_fill_holes(seg)
416
+
417
+ def _needs_edge_fallback(self, segmentation: np.ndarray) -> bool:
418
+ """Check if edge fallback processing is needed."""
419
+ binary_seg = segmentation.astype(np.uint8)
420
+ total_pixels = binary_seg.size
421
+ white_pixels = np.count_nonzero(binary_seg)
422
+ white_ratio = white_pixels / total_pixels
423
+ region_count = self.get_region_count(binary_seg)
424
+ return white_ratio > 0.8 or region_count == 1
425
+
426
+ def _meets_size_requirements(self, area: int, width: int, height: int, image_area: int, img_width: int, img_height: int) -> bool:
427
+ """Check if region meets minimum size requirements."""
428
+ return (area < self.config.min_area_ratio * image_area or
429
+ width < self.config.min_width_ratio * img_width or
430
+ height < self.config.min_height_ratio * img_height)
431
+
432
+ def _is_mostly_white_region(self, region, idx: int) -> bool:
433
+ """Check if region is mostly white (allowing small percentage of black)."""
434
+ black_pixel_count = np.count_nonzero(region.image == 0)
435
+ total_pixels = region.image.size
436
+ black_ratio = black_pixel_count / total_pixels
437
+
438
+ if black_ratio > 0.1:
439
+ print(f"❌ Region #{idx} rejected — {round(black_ratio * 100, 2)}% black pixels")
440
+ self._save_black_region_debug(region, idx)
441
+ return False
442
+ return True
443
+
444
+ def _save_black_region_debug(self, region, idx: int) -> None:
445
+ """Save debug information for rejected black regions."""
446
+ debug_dir = os.path.join(self.output_folder, f"region_{idx}_skipped_black_inside")
447
+ os.makedirs(debug_dir, exist_ok=True)
448
+
449
+ # Create highlighted visualization
450
+ highlighted = np.stack([region.image] * 3, axis=-1) * 255
451
+ highlighted[region.image == 0] = [255, 0, 0] # Red for black pixels
452
+
453
+ # Save zoomed version
454
+ highlighted_img = Image.fromarray(highlighted.astype('uint8'))
455
+ zoomed = highlighted_img.resize(
456
+ (highlighted.shape[1] * 4, highlighted.shape[0] * 4),
457
+ resample=Image.NEAREST
458
+ )
459
+ zoomed.save(os.path.join(debug_dir, f"region_{idx}_highlight_black_zoomed.jpg"))
460
+
461
+ def _save_panel(self, original_image, accepted_boxes) -> str:
462
+ """Save extracted panel with coordinates in filename."""
463
+ orig_pil = Image.fromarray(original_image.copy())
464
+ panel_idx = 0
465
+ all_paths = []
466
+ for minc, minr, maxc, maxr in accepted_boxes:
467
+ panel_idx += 1
468
+ bbox_str = f"({minc}, {minr}, {maxc}, {maxr})"
469
+ panel_path = os.path.join(self.config.output_folder, f"panel_{panel_idx}_{bbox_str}.jpg")
470
+ cropped_img = orig_pil.crop((minc, minr, maxc, maxr))
471
+ cropped_img.save(panel_path)
472
+ all_paths.append(panel_path)
473
+
474
+ print(f'✅ Extracted {len(all_paths)} panels.')
475
+ return all_paths
476
+
477
+ def _save_debug_panel(self, orig_pil: Image.Image, segmentation_mask: np.ndarray, minr: int, minc: int, maxr: int, maxc: int, idx: int, debug_region_dir: str) -> None:
478
+ """Save debug images for accepted panels."""
479
+ crop_name_prefix = f"region_{idx+1}"
480
+
481
+ # Save cropped original
482
+ cropped_img = orig_pil.crop((minc, minr, maxc, maxr))
483
+ cropped_img.save(os.path.join(debug_region_dir, f"{crop_name_prefix}_saved_orig.jpg"))
484
+
485
+ # Save cropped mask
486
+ cropped_mask = segmentation_mask[minr:maxr, minc:maxc]
487
+ mask_pil = Image.fromarray((cropped_mask * 255).astype('uint8'))
488
+ mask_pil.save(os.path.join(debug_region_dir, f"{crop_name_prefix}_saved_mask.jpg"))
489
+
490
+ def _create_visualization(self, orig_pil: Image.Image, accepted_boxes: list, file_name: str) -> None:
491
+ """Create debug image showing all accepted panel boxes."""
492
+ debug_img = orig_pil.copy()
493
+ draw = ImageDraw.Draw(debug_img)
494
+ for (x1, y1, x2, y2) in accepted_boxes:
495
+ draw.rectangle([x1, y1, x2, y2], outline="red", width=10)
496
+ debug_img.save(os.path.join(self.output_folder, file_name))
497
+
498
+ def extend_to_nearby_boxes(self, boxes, image_shape):
499
+ """
500
+ Extend smaller boxes to the edge of close larger boxes, without merging or reducing the box count.
501
+
502
+ A box is represented by (x1, y1, x2, y2).
503
+ """
504
+ if not boxes:
505
+ return boxes
506
+ extended_boxes = [list(box) for box in boxes]
507
+ height, width = image_shape[:2]
508
+
509
+ width_threshold = min(x2 - x1 for x1, y1, x2, y2 in extended_boxes)
510
+ height_threshold = min(y2 - y1 for x1, y1, x2, y2 in extended_boxes)
511
+
512
+ # width_threshold = self.config.min_width_ratio * width
513
+ # height_threshold = self.config.min_height_ratio * height
514
+
515
+ # print(f"[DEBUG] Image Shape: {image_shape}, Width Threshold: {width_threshold:.2f}, Height Threshold: {height_threshold:.2f}\n")
516
+
517
+ for i in range(len(extended_boxes)):
518
+ for j in range(len(extended_boxes)):
519
+ if i == j:
520
+ continue
521
+
522
+ box1 = extended_boxes[i]
523
+ box2 = extended_boxes[j]
524
+
525
+ area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
526
+ area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
527
+
528
+ if area1 >= area2:
529
+ continue
530
+
531
+ # print(f"[DEBUG] Comparing smaller Box {i} {box1} with larger Box {j} {box2}")
532
+
533
+ x1_1, y1_1, x2_1, y2_1 = box1
534
+ x1_2, y1_2, x2_2, y2_2 = box2
535
+
536
+ # Horizontal Extension Check
537
+ is_vertically_aligned = (y1_1 < y2_2 and y2_1 > y1_2)
538
+ if is_vertically_aligned:
539
+ gap_right = x1_2 - x2_1
540
+ if 0 < gap_right <= width_threshold:
541
+ # print(f" [INFO] Extending right of Box {i}. Gap ({gap_right:.2f}) <= Threshold ({width_threshold:.2f})")
542
+ extended_boxes[i][2] = x1_2
543
+ # elif gap_right > width_threshold:
544
+ # print(f" [DEBUG] Did not extend right: Gap ({gap_right:.2f}) > Threshold ({width_threshold:.2f})")
545
+
546
+ gap_left = x1_1 - x2_2
547
+ if 0 < gap_left <= width_threshold:
548
+ # print(f" [INFO] Extending left of Box {i}. Gap ({gap_left:.2f}) <= Threshold ({width_threshold:.2f})")
549
+ extended_boxes[i][0] = x2_2
550
+ # elif gap_left > width_threshold:
551
+ # print(f" [DEBUG] Did not extend left: Gap ({gap_left:.2f}) > Threshold ({width_threshold:.2f})")
552
+ # else:
553
+ # print(f" [DEBUG] Not vertically aligned for horizontal extension.")
554
+
555
+
556
+ # Vertical Extension Check
557
+ is_horizontally_aligned = (x1_1 < x2_2 and x2_1 > x1_2)
558
+ if is_horizontally_aligned:
559
+ gap_bottom = y1_2 - y2_1
560
+ if 0 < gap_bottom <= height_threshold:
561
+ # print(f" [INFO] Extending bottom of Box {i}. Gap ({gap_bottom:.2f}) <= Threshold ({height_threshold:.2f})")
562
+ extended_boxes[i][3] = y1_2
563
+ # elif gap_bottom > height_threshold:
564
+ # print(f" [DEBUG] Did not extend bottom: Gap ({gap_bottom:.2f}) > Threshold ({height_threshold:.2f})")
565
+
566
+ gap_top = y1_1 - y2_2
567
+ if 0 < gap_top <= height_threshold:
568
+ # print(f" [INFO] Extending top of Box {i}. Gap ({gap_top:.2f}) <= Threshold ({height_threshold:.2f})")
569
+ extended_boxes[i][1] = y2_2
570
+ # elif gap_top > height_threshold:
571
+ # print(f" [DEBUG] Did not extend top: Gap ({gap_top:.2f}) > Threshold ({height_threshold:.2f})")
572
+ # else:
573
+ # print(f" [DEBUG] Not horizontally aligned for vertical extension.")
574
+ # print("-" * 20)
575
+
576
+ return [tuple(box) for box in extended_boxes]
577
+
578
+ def threshold_based_filter(self, boxes, image_shape):
579
+ img_h, img_w = image_shape[:2]
580
+ image_area = img_h * img_w
581
+
582
+ filtered_box = []
583
+ for x1, y1, x2, y2 in boxes:
584
+ w, h = x2 - x1, y2 - y1
585
+ area = w * h
586
+
587
+ if self._meets_size_requirements(area, w, h, image_area, img_w, img_h):
588
+ continue
589
+
590
+ filtered_box.append((x1, y1, x2, y2))
591
+
592
+ return filtered_box
593
+
594
+ def _apply_additional_processing(self, segmentation_mask_path: str) -> np.ndarray:
595
+ """Apply additional image processing steps when needed."""
596
+ image_processor = ImageProcessor()
597
+
598
+ # Step 5: Thicken black lines
599
+ processed_path = image_processor.thick_black(
600
+ segmentation_mask_path,
601
+ file_name="04_thick.jpg",
602
+ output_folder=f"{self.output_folder}"
603
+ )
604
+
605
+ # Step 6: Connect gaps
606
+ processed_path = image_processor.connect_horizontal_vertical_gaps(
607
+ processed_path,
608
+ file_name="05_continuity.jpg",
609
+ output_folder=f"{self.output_folder}"
610
+ )
611
+
612
+ # Check if more processing is needed
613
+ pixel_ratios = self.get_black_white_ratio(processed_path)
614
+ if pixel_ratios['black_ratio'] < 0.8:
615
+ # Additional processing steps
616
+ processed_path = image_processor.thin_image_borders(
617
+ processed_path,
618
+ file_name="06_thin.jpg",
619
+ output_folder=f"{self.output_folder}"
620
+ )
621
+
622
+ processed_path = image_processor.remove_dangling_lines(
623
+ processed_path,
624
+ file_name="07_remove_dangling_lines.jpg",
625
+ output_folder=f"{self.output_folder}"
626
+ )
627
+
628
+ processed_path = image_processor.thick_black(
629
+ processed_path,
630
+ file_name="08_thick.jpg",
631
+ output_folder=f"{self.output_folder}"
632
+ )
633
+
634
+ return cv2.imread(processed_path, cv2.IMREAD_GRAYSCALE), processed_path
635
+
636
+
637
+ if __name__ == "__main__":
638
+ config = Config()
639
+ config.input_path = "test0.jpg"
640
+
641
+ import shutil
642
+ shutil.rmtree(config.output_folder, ignore_errors=True)
643
+
644
+ extractor = BorderPanelExtractor(config)
645
+ result_path = extractor.main()
646
+ print(f"Processing complete. Result saved to: {result_path}")
comic_panel_extractor/config.py CHANGED
@@ -13,6 +13,9 @@ class Config:
13
  min_area_ratio: float = 0.05
14
  min_width_ratio: float = 0.05
15
  min_height_ratio: float = 0.1
 
 
 
16
 
17
  def get_text_cood_file_path(config: Config):
18
  return f'{config.output_folder}/{config.text_cood_file_name}'
 
13
  min_area_ratio: float = 0.05
14
  min_width_ratio: float = 0.05
15
  min_height_ratio: float = 0.1
16
+
17
+ # Additional parameters for BorderPanelExtractor
18
+ panel_filename_pattern: str = r"panel_\d+_\((\d+), (\d+), (\d+), (\d+)\)\.jpg"
19
 
20
  def get_text_cood_file_path(config: Config):
21
  return f'{config.output_folder}/{config.text_cood_file_name}'
comic_panel_extractor/image_processor.py CHANGED
@@ -239,9 +239,10 @@ class ImageProcessor:
239
 
240
  # Bounding box filter
241
  if (width < width_ * self.config.min_width_ratio or height < height_ * self.config.min_height_ratio):
242
- clean_mask[labeled == region.label] = 0 # Remove small region
243
- cv2.rectangle(visual, (minc, minr), (maxc, maxr), (0, 0, 255), 2)
244
- continue
 
245
 
246
  # Crop and analyze region for line orientation
247
  region_crop = binary[minr:maxr, minc:maxc]
@@ -270,7 +271,8 @@ class ImageProcessor:
270
  cv2.rectangle(visual, (minc, minr), (maxc, maxr), (255, 0, 0), 2)
271
 
272
  # Save debug visualization
273
- cv2.imwrite(f"{output_folder}/debug_{file_name}", visual)
 
274
 
275
  # Invert back to original format: black lines on white
276
  cleaned = cv2.bitwise_not(clean_mask)
 
239
 
240
  # Bounding box filter
241
  if (width < width_ * self.config.min_width_ratio or height < height_ * self.config.min_height_ratio):
242
+ if (width/width_) < 0.9 and (height/height_) < 0.9:
243
+ clean_mask[labeled == region.label] = 0 # Remove small region
244
+ cv2.rectangle(visual, (minc, minr), (maxc, maxr), (0, 0, 255), 2)
245
+ continue
246
 
247
  # Crop and analyze region for line orientation
248
  region_crop = binary[minr:maxr, minc:maxc]
 
271
  cv2.rectangle(visual, (minc, minr), (maxc, maxr), (255, 0, 0), 2)
272
 
273
  # Save debug visualization
274
+ output_path = self.get_output_path(output_folder, f"debug_{file_name}")
275
+ cv2.imwrite(output_path, visual)
276
 
277
  # Invert back to original format: black lines on white
278
  cleaned = cv2.bitwise_not(clean_mask)
comic_panel_extractor/main.py CHANGED
@@ -8,7 +8,7 @@ from .panel_segmentation import main as basic_panel_segmentation
8
  from typing import List, Tuple
9
  from pathlib import Path
10
  import numpy as np
11
- import json
12
  import shutil
13
 
14
  class ComicPanelExtractor:
@@ -28,7 +28,8 @@ class ComicPanelExtractor:
28
  """Complete pipeline to extract panels from a comic image."""
29
  print(f"Starting panel extraction for: {self.config.input_path}")
30
 
31
- processed_image_path = basic_panel_segmentation(self.config.output_folder, self.config.input_path, self.config.input_path)
 
32
  self.config.black_overlay_input_path = processed_image_path
33
 
34
  _, _, processed_image_path = self.image_processor.preprocess_image(processed_image_path)
 
8
  from typing import List, Tuple
9
  from pathlib import Path
10
  import numpy as np
11
+ from .border_panel_extractor import BorderPanelExtractor
12
  import shutil
13
 
14
  class ComicPanelExtractor:
 
28
  """Complete pipeline to extract panels from a comic image."""
29
  print(f"Starting panel extraction for: {self.config.input_path}")
30
 
31
+ processed_image_path = BorderPanelExtractor(self.config).main()
32
+
33
  self.config.black_overlay_input_path = processed_image_path
34
 
35
  _, _, processed_image_path = self.image_processor.preprocess_image(processed_image_path)
comic_panel_extractor/utils.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def remove_duplicate_boxes(boxes, compare_single=None, iou_threshold=0.7):
2
+ """
3
+ Removes duplicate or highly overlapping boxes, keeping the larger one.
4
+ :param boxes: List of (x1, y1, x2, y2) boxes.
5
+ :param compare_single: Optional single box to compare against the list.
6
+ :param iou_threshold: IOU threshold to consider as duplicate.
7
+ :return:
8
+ - If compare_single is None: deduplicated list of boxes.
9
+ - If compare_single is provided: tuple (is_duplicate, updated_box_or_none)
10
+ """
11
+ def compute_iou(boxA, boxB):
12
+ xA = max(boxA[0], boxB[0])
13
+ yA = max(boxA[1], boxB[1])
14
+ xB = min(boxA[2], boxB[2])
15
+ yB = min(boxA[3], boxB[3])
16
+ interArea = max(0, xB - xA) * max(0, yB - yA)
17
+ if interArea == 0:
18
+ return 0.0
19
+ boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
20
+ boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
21
+ return interArea / float(boxAArea + boxBArea - interArea)
22
+
23
+ def compute_area(box):
24
+ return (box[2] - box[0]) * (box[3] - box[1])
25
+
26
+ # Single comparison mode
27
+ if compare_single is not None:
28
+ single_area = compute_area(compare_single)
29
+ for existing_box in boxes:
30
+ iou = compute_iou(compare_single, existing_box)
31
+ if iou > iou_threshold:
32
+ existing_area = compute_area(existing_box)
33
+ if single_area > existing_area:
34
+ return True, compare_single # Keep new (larger) box
35
+ else:
36
+ return True, None # Existing box is better, discard new
37
+ return False, compare_single # No overlap found, keep it
38
+
39
+ # Bulk deduplication mode
40
+ unique_boxes = []
41
+ for box in boxes:
42
+ box_area = compute_area(box)
43
+ replaced_existing = False
44
+
45
+ # Check against existing unique boxes
46
+ for i, ubox in enumerate(unique_boxes):
47
+ if compute_iou(box, ubox) > iou_threshold:
48
+ ubox_area = compute_area(ubox)
49
+ # If current box is larger, replace the existing one
50
+ if box_area > ubox_area:
51
+ unique_boxes[i] = box
52
+ replaced_existing = True
53
+ # If existing box is larger or equal, ignore current box
54
+ break
55
+
56
+ # If no overlap found, add the box
57
+ if not replaced_existing and not any(compute_iou(box, ubox) > iou_threshold for ubox in unique_boxes):
58
+ unique_boxes.append(box)
59
+
60
+ print(f"✅ Found {abs(len(unique_boxes) - len(boxes))} duplicates")
61
+ return unique_boxes