File size: 28,251 Bytes
8c82386
 
 
 
 
 
 
 
 
 
 
 
652f463
 
 
 
 
8c82386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652f463
 
 
8c82386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652f463
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
import numpy as np
import cv2
import os
import argparse
from pathlib import Path
from skimage.feature import canny
from skimage.morphology import dilation
from scipy import ndimage as ndi
from skimage.measure import label, regionprops
from skimage.color import rgb2gray, label2rgb
from PIL import Image, ImageDraw

try:
    import matplotlib.pyplot as plt
except Exception:
    plt = None

class MangaPanelSegmenter:
    """
    A class for segmenting manga/manhwa pages into individual artwork panels,
    excluding speech bubbles and text elements.
    """

    def __init__(self, min_panel_area_ratio=0.01, edge_detection_sigma=1.0,
                 low_threshold=0.1, high_threshold=0.2):
        """
        Initialize the segmenter with configurable parameters.

        Args:
            min_panel_area_ratio: Minimum panel area as a ratio of page area
            edge_detection_sigma: Sigma parameter for Canny edge detection
            low_threshold: Low threshold for Canny edge detection
            high_threshold: High threshold for Canny edge detection
        """
        self.min_panel_area_ratio = min_panel_area_ratio
        self.edge_detection_sigma = edge_detection_sigma
        self.low_threshold = low_threshold
        self.high_threshold = high_threshold
        # Initialize attributes to None
        self.original_image = None
        self.grayscale = None
        self.masked_grayscale = None
        self.segmentation = None
        self.labels = None
        self.panels = []
        self.extracted_panels = []
        self.ordered_panels = []
        self.height = 0
        self.width = 0


    def load_image(self, image_path):
        """Load the manga page image."""
        try:
            self.original_image = cv2.imread(image_path)
            if self.original_image is None:
                raise ValueError(f"Could not read image file: {image_path}")
            self.original_image = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2RGB)
            self.height, self.width = self.original_image.shape[:2]
            return self.original_image
        except Exception as e:
            print(f"Error loading image {image_path}: {e}")
            return None

    def preprocess_for_edge_detection(self):
        """
        Preprocess the image to highlight panel borders and reduce noise from text.
        Apply techniques to ignore speech bubbles extending outside panels.
        """
        if self.original_image is None: return None
        # Convert to grayscale
        grayscale = rgb2gray(self.original_image)
        self.grayscale = grayscale

        # Create a binary threshold to identify potential speech bubbles (white areas)
        speech_bubble_mask = grayscale > 0.9  # White/near-white areas

        # Dilate the speech bubble mask to ensure we capture the entire bubbles
        speech_bubble_mask = dilation(speech_bubble_mask, np.ones((5, 5)))

        # Create an edge detection mask that excludes speech bubbles
        # edge_mask = ~speech_bubble_mask # This wasn't actually used, removing

        # Apply the mask to the grayscale image to suppress speech bubbles
        masked_grayscale = grayscale.copy()
        masked_grayscale[speech_bubble_mask] = 0.5  # Set speech bubble areas to mid-gray

        self.masked_grayscale = masked_grayscale
        return masked_grayscale

    def detect_edges(self):
        """
        Detect edges in the image using Canny edge detection.
        Focus on panel boundaries while ignoring speech bubbles.
        """
        masked_grayscale = self.preprocess_for_edge_detection()
        if masked_grayscale is None: return None

        # Apply Canny edge detection on the masked grayscale
        edges = canny(
            masked_grayscale,
            sigma=self.edge_detection_sigma,
            low_threshold=self.low_threshold,
            high_threshold=self.high_threshold
        )

        # Thicken edges through dilation to ensure panel borders are continuous
        # Applying dilation twice for potentially thicker lines
        thick_edges = dilation(dilation(edges))

        # Fill holes to create solid regions
        self.segmentation = ndi.binary_fill_holes(thick_edges)

        return self.segmentation

    def identify_panels(self):
        """
        Identify individual artwork panels from the segmented image.
        Ignore speech bubbles and focus on the main panel boundaries.
        """
        if self.segmentation is None or self.grayscale is None: return []

        # Label each region
        labels = label(self.segmentation)
        self.labels = labels

        # Extract region properties
        regions = regionprops(labels)

        # Find panel regions (excluding speech bubbles)
        initial_panels = []

        # Create a binary mask for potential speech bubbles
        speech_bubble_mask = self.grayscale > 0.9  # White areas

        # --- Simplified Panel Identification ---
        # Instead of complex overlap and merging, treat large non-background regions as potential panels
        min_area = self.min_panel_area_ratio * self.height * self.width

        for region in regions:
            # Check if region is large enough (ignore tiny artifacts and background)
            if region.area < min_area or region.label == 0: # region.label==0 is background
                continue

            y_min, x_min, y_max, x_max = region.bbox

            # --- Refined Check: Avoid primarily white regions (likely background gaps or large bubbles) ---
            region_mask_in_bbox = (labels == region.label)[y_min:y_max, x_min:x_max]
            region_pixels_in_bbox = self.grayscale[y_min:y_max, x_min:x_max][region_mask_in_bbox]
            white_ratio_in_region = np.sum(region_pixels_in_bbox > 0.9) / region.area
            
            # Skip regions that are primarily white (adjust threshold as needed)
            if white_ratio_in_region > 0.85:
                 # print(f"Skipping region {region.label} due to high white ratio: {white_ratio_in_region:.2f}")
                 continue

            initial_panels.append(region.bbox)


        # --- Merge Overlapping Bounding Boxes ---
        # This step helps combine regions that might be fragmented parts of the same panel
        if not initial_panels:
            self.panels = []
            return []

        merged = True
        while merged:
            merged = False
            merged_panels = []
            used = [False] * len(initial_panels)

            for i in range(len(initial_panels)):
                if used[i]:
                    continue
                current_bbox = list(initial_panels[i]) # Make mutable
                used[i] = True

                for j in range(i + 1, len(initial_panels)):
                    if used[j]:
                        continue
                    # Check for significant overlap (IoU or simple overlap ratio)
                    if self._do_bboxes_overlap(current_bbox, initial_panels[j]):
                         # Merge overlapping boxes
                         current_bbox = self._merge_bboxes(current_bbox, initial_panels[j])
                         used[j] = True
                         merged = True # Indicate that a merge happened in this pass

                merged_panels.append(tuple(current_bbox)) # Add the potentially merged box

            initial_panels = merged_panels # Update list for next potential merge pass

        # --- Final Filtering (Optional: Remove panels fully contained within others) ---
        final_panels = []
        initial_panels.sort(key=lambda b: (b[2]-b[0])*(b[3]-b[1]), reverse=True) # Sort by area desc

        for i, p1 in enumerate(initial_panels):
            is_contained = False
            for j, p2 in enumerate(initial_panels):
                if i != j and self._is_bbox_contained(p1, p2):
                    is_contained = True
                    break
            if not is_contained:
                 # Further check: ensure it's not too small after merging/filtering
                 y_min, x_min, y_max, x_max = p1
                 if (y_max - y_min) * (x_max - x_min) >= min_area:
                    final_panels.append(p1)


        # Assign to self.panels
        self.panels = final_panels
        # print(f"Identified {len(self.panels)} panels.")
        return self.panels


    def extract_artwork_panels(self):
        """
        Extract the artwork panels from the original image based on identified bounding boxes.
        """
        if self.original_image is None: return []
        self.extracted_panels = []

        for i, bbox in enumerate(self.panels):
            # Ensure bbox coordinates are valid
            y_min, x_min, y_max, x_max = bbox
            y_min = max(0, y_min)
            x_min = max(0, x_min)
            y_max = min(self.height, y_max)
            x_max = min(self.width, x_max)

            # Check if coordinates are still valid after clipping
            if y_min >= y_max or x_min >= x_max:
                 # print(f"Skipping invalid bbox after clipping: {bbox}")
                 continue

            # Extract the panel
            panel = self.original_image[y_min:y_max, x_min:x_max].copy()

            # Store the panel and its bounding box
            self.extracted_panels.append({
                'bbox': bbox,
                'panel': panel,
                'panel_id': i + 1 # Temporary ID, will be updated after ordering
            })

        return self.extracted_panels

    def order_panels(self, reading_order='ltr'):
        """
        Order the panels according to typical reading order.
        Args:
            reading_order (str): 'ltr' (left-to-right) or 'rtl' (right-to-left).
                                 Default is 'ltr'.
        """
        if not self.extracted_panels:
            self.ordered_panels = []
            return []

        # Sort panels primarily by their vertical position (top-to-bottom).
        # For panels roughly on the same horizontal line, sort by horizontal position.
        # A tolerance is used to group panels that are vertically close.
        vertical_tolerance = (self.height * 0.05) # e.g., 5% of page height tolerance

        # Group panels by approximate vertical position
        rows = []
        sorted_by_y = sorted(self.extracted_panels, key=lambda p: p['bbox'][0]) # Sort by y_min

        if not sorted_by_y:
            self.ordered_panels = []
            return []

        current_row = [sorted_by_y[0]]
        last_y_min = sorted_by_y[0]['bbox'][0]

        for panel in sorted_by_y[1:]:
            y_min = panel['bbox'][0]
            # If the current panel's top is close to the last panel's top, consider it in the same row
            if abs(y_min - last_y_min) < vertical_tolerance:
                current_row.append(panel)
                # Update last_y_min to the average or min y of the current row for better grouping?
                # For simplicity, let's keep comparing with the first element's top in the row group
            else:
                # Start a new row
                rows.append(current_row)
                current_row = [panel]
                last_y_min = y_min

        rows.append(current_row) # Add the last row

        # Sort panels within each row based on reading order
        self.ordered_panels = []
        for row in rows:
            if reading_order == 'rtl':
                # Sort right-to-left based on the left edge (x_min)
                sorted_row = sorted(row, key=lambda p: p['bbox'][1], reverse=True)
            else: # Default ltr
                # Sort left-to-right based on the left edge (x_min)
                sorted_row = sorted(row, key=lambda p: p['bbox'][1])
            self.ordered_panels.extend(sorted_row)

        # Reassign panel IDs based on the final order
        for i, panel in enumerate(self.ordered_panels):
            panel['panel_id'] = i + 1

        return self.ordered_panels

    def save_panels(self, output_dir, base_filename, original_extension):
        """
        Save the extracted artwork panels as individual image files using
        the specified naming convention.

        Args:
            output_dir (str): Directory to save the panels.
            base_filename (str): The original image filename without extension (e.g., "page001").
            original_extension (str): The original image file extension (e.g., ".jpg").
        """
        # This function assumes output_dir already exists
        if not self.ordered_panels:
            print("No panels to save.")
            return 0

        saved_count = 0
        for panel_data in self.ordered_panels:
            panel = panel_data['panel']
            panel_id = panel_data['panel_id']

            # Construct the output filename: base_filename_panelID.original_extension
            # Use 2-digit padding for panel ID
            output_filename = f"{base_filename}_{panel_id:02d}{original_extension}"
            output_path = os.path.join(output_dir, output_filename)

            try:
                # Convert back to BGR for saving with OpenCV/PIL if needed
                if panel.ndim == 3 and panel.shape[2] == 3: # Check if it's an RGB image
                     panel_bgr = cv2.cvtColor(panel, cv2.COLOR_RGB2BGR)
                else:
                     panel_bgr = panel # Assume grayscale or already BGR

                # Save the panel using PIL to handle various formats potentially better
                panel_img = Image.fromarray(panel) # Use original RGB panel for PIL
                panel_img.save(output_path)
                saved_count += 1
            except Exception as e:
                print(f"Error saving panel {output_path}: {e}")

        return saved_count

    def visualize_results(self, show_original=True, show_edges=True, show_panels=True):
        """
        Visualize the extraction results, showing the journey from source image to final panels.
        Displays multiple stages of the process with clear explanations.
        """
        if plt is None:
            print("matplotlib is not installed, skipping visualization.")
            return
        if self.original_image is None:
            print("No image loaded to visualize.")
            return

        num_stages = 0
        if show_original: num_stages += 1
        if show_edges and self.segmentation is not None: num_stages +=1
        # Add more stages if needed

        plt.figure(figsize=(15, 5 * num_stages)) # Adjust figure size

        plot_index = 1

        # --- Plot 1: Original Image ---
        if show_original:
            plt.subplot(num_stages, 3, plot_index)
            plt.imshow(self.original_image)
            plt.title('1. Original Image')
            plt.axis('off')
            plot_index += 1

        # --- Plot 2: Edge Detection / Segmentation ---
        if show_edges and self.segmentation is not None:
             plt.subplot(num_stages, 3, plot_index)
             plt.imshow(self.segmentation, cmap='gray')
             plt.title('2. Edge Detection & Segmentation')
             plt.axis('off')
             plot_index += 1


        # --- Plot 3: Identified Panel Boundaries ---
        if self.panels:
            panel_boundary_img = self.original_image.copy()
            for bbox in self.panels:
                 y_min, x_min, y_max, x_max = [int(c) for c in bbox] # Ensure int
                 cv2.rectangle(panel_boundary_img, (x_min, y_min), (x_max, y_max), (255, 0, 0), 2) # Red boxes
            plt.subplot(num_stages, 3, plot_index)
            plt.imshow(panel_boundary_img)
            plt.title('3. Identified Panel Boundaries')
            plt.axis('off')
            plot_index += 1


        # --- Plot 4: Final Panels with Order ---
        if self.ordered_panels:
            img_with_boxes = self.original_image.copy()
            for panel_data in self.ordered_panels:
                bbox = panel_data['bbox']
                panel_id = panel_data['panel_id']
                y_min, x_min, y_max, x_max = [int(c) for c in bbox] # Ensure int

                # Draw rectangle
                cv2.rectangle(img_with_boxes, (x_min, y_min), (x_max, y_max), (0, 255, 0), 2) # Green boxes

                # Add panel number (position inside the box)
                text_x = x_min + 10
                text_y = y_min + 30
                # Basic check to keep text within image bounds
                if text_y > y_max: text_y = y_min + 15
                if text_x > x_max - 20 : text_x = x_min + 5

                cv2.putText(img_with_boxes, str(panel_id), (text_x, text_y),
                            cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) # Blue text

            plt.subplot(num_stages, 3, plot_index) # Or adjust layout as needed
            plt.imshow(img_with_boxes)
            plt.title('4. Final Ordered Panels')
            plt.axis('off')
            plot_index += 1


        plt.tight_layout()
        plt.suptitle('Manga Panel Segmentation Stages', fontsize=16, y=1.02) # Adjust title pos
        plt.show()


        # --- Figure 2: Display Extracted Panels ---
        if show_panels and self.ordered_panels:
            num_panels = len(self.ordered_panels)
            if num_panels > 0:
                max_cols = 4 # Increased columns for potentially more panels
                rows = int(np.ceil(num_panels / max_cols))
                cols = min(max_cols, num_panels)

                fig_panels, panel_axes = plt.subplots(rows, cols, figsize=(3 * cols, 3 * rows + 1)) # Adjust size
                # Handle case of single panel or single row/column
                if num_panels == 1:
                     panel_axes = np.array([panel_axes]) # Make iterable
                panel_axes = panel_axes.flatten() # Flatten for easy iteration

                for i, panel_data in enumerate(self.ordered_panels):
                    panel_axes[i].imshow(panel_data['panel'])
                    panel_axes[i].set_title(f'Panel {panel_data["panel_id"]}')
                    panel_axes[i].axis('off')

                # Hide any unused subplots
                for i in range(num_panels, len(panel_axes)):
                    panel_axes[i].axis('off')

                plt.tight_layout()
                plt.suptitle('Extracted Panels', fontsize=16, y=1.0) # Adjust title pos
                plt.show()


    def _do_bboxes_overlap(self, a, b, threshold=0.1):
        """Check if two bounding boxes overlap significantly."""
        y_min_a, x_min_a, y_max_a, x_max_a = a
        y_min_b, x_min_b, y_max_b, x_max_b = b

        # Calculate intersection coordinates
        intersect_ymin = max(y_min_a, y_min_b)
        intersect_xmin = max(x_min_a, x_min_b)
        intersect_ymax = min(y_max_a, y_max_b)
        intersect_xmax = min(x_max_a, x_max_b)

        # Calculate intersection area
        intersect_height = max(0, intersect_ymax - intersect_ymin)
        intersect_width = max(0, intersect_xmax - intersect_xmin)
        intersection_area = intersect_height * intersect_width

        if intersection_area == 0:
            return False

        # Calculate area of bounding boxes
        area_a = (y_max_a - y_min_a) * (x_max_a - x_min_a)
        area_b = (y_max_b - y_min_b) * (x_max_b - x_min_b)

        # Check if intersection area is a significant portion of either box
        if area_a > 0 and (intersection_area / area_a) > threshold:
            return True
        if area_b > 0 and (intersection_area / area_b) > threshold:
            return True

        return False


    def _merge_bboxes(self, a, b):
        """Merge two overlapping bounding boxes."""
        return (
            min(a[0], b[0]),
            min(a[1], b[1]),
            max(a[2], b[2]),
            max(a[3], b[3])
        )

    def _is_bbox_contained(self, inner_bbox, outer_bbox):
         """Check if inner_bbox is fully contained within outer_bbox."""
         y_min_i, x_min_i, y_max_i, x_max_i = inner_bbox
         y_min_o, x_min_o, y_max_o, x_max_o = outer_bbox
         return (y_min_o <= y_min_i and x_min_o <= x_min_i and
                 y_max_o >= y_max_i and x_max_o >= x_max_i)


# %% Processing Function
def process_image(image_path, output_dir, visualize=True,
                  edge_detection_sigma=1.0, low_threshold=0.1, high_threshold=0.2,
                  min_panel_area=0.01, reading_order='ltr'):
    """
    Process a single manga/manhwa image and extract panels.

    Args:
        image_path (str): Path to the input image.
        output_dir (str): Directory to save extracted panels.
        visualize (bool): Whether to show visualization of results.
        edge_detection_sigma (float): Sigma parameter for Canny edge detection.
        low_threshold (float): Low threshold for Canny edge detection.
        high_threshold (float): High threshold for Canny edge detection.
        min_panel_area (float): Minimum panel area as a ratio of page area.
        reading_order (str): 'ltr' or 'rtl' for panel ordering.

    Returns:
        int: Number of panels extracted, or 0 if processing failed.
    """
    print(f"Processing: {image_path}")
    # Ensure output directory exists
    os.makedirs(output_dir, exist_ok=True)

    # Extract base filename and extension for saving panels later
    path_obj = Path(image_path)
    base_filename = path_obj.stem
    original_extension = path_obj.suffix # Includes the dot (e.g., ".jpg")

    # Initialize segmenter with parameters
    segmenter = MangaPanelSegmenter(
        min_panel_area_ratio=min_panel_area,
        edge_detection_sigma=edge_detection_sigma,
        low_threshold=low_threshold,
        high_threshold=high_threshold
    )

    # Process the image
    if segmenter.load_image(image_path) is None:
        return 0 # Image loading failed
    if segmenter.detect_edges() is None:
        print(f" Edge detection failed for {image_path}")
        return 0
    segmenter.identify_panels()
    segmenter.extract_artwork_panels()
    segmenter.order_panels(reading_order=reading_order)

    # Save panels
    num_panels = segmenter.save_panels(output_dir, base_filename, original_extension)
    if num_panels > 0:
        print(f"-> Successfully extracted {num_panels} panels to {output_dir}/")
    else:
        print(f"-> No panels extracted or saved for {image_path}")

    # Visualize results if requested
    if visualize:
        print(f" Visualizing results for {base_filename}{original_extension}...")
        segmenter.visualize_results(show_panels=(num_panels > 0)) # Only show panels if any were extracted

    return num_panels

# %% Main Function
def main():
    """
    Main function to run the manga panel segmentation from command line.
    Handles both single image and directory processing.
    """
    parser = argparse.ArgumentParser(description='Manga/Manhwa Panel Segmentation Tool')
    parser.add_argument('input_path', type=str,
                        help='Path to the manga/manhwa page image OR a directory containing images.')
    parser.add_argument('--output', type=str, default=None, # Default is None, logic will handle it
                        help='Output directory base. If input is a file, panels go here.'
                             ' If input is a directory, a sibling directory named <input_dir>_output is created,'
                             ' and this argument is ignored.')
    parser.add_argument('--visualize', action='store_true',
                        help='Visualize the segmentation results for each image.')
    parser.add_argument('--min-panel-area', type=float, default=0.01,
                        help='Minimum panel area as a ratio of total page area (e.g., 0.01 = 1%%). Default: 0.01')
    parser.add_argument('--edge-sigma', type=float, default=1.5, # Slightly higher default sigma might help
                        help='Sigma for Gaussian filter in Canny edge detection. Default: 1.5')
    parser.add_argument('--low-threshold', type=float, default=0.1,
                        help='Low threshold for Canny edge detection (as fraction of max intensity). Default: 0.1')
    parser.add_argument('--high-threshold', type=float, default=0.2,
                        help='High threshold for Canny edge detection (as fraction of max intensity). Default: 0.2')
    parser.add_argument('--reading-order', type=str, default='ltr', choices=['ltr', 'rtl'],
                        help='Reading order for panel numbering (ltr or rtl). Default: ltr')

    args = parser.parse_args()

    input_path = Path(args.input_path)
    output_base = args.output

    if not input_path.exists():
        print(f"Error: Input path does not exist: {args.input_path}")
        return

    # --- Determine if input is directory or file ---
    if input_path.is_dir():
        # --- Directory Processing ---
        print(f"Processing directory: {input_path}")

        # Create the output directory path (sibling with _output suffix)
        output_dir_name = f"{input_path.name}_output"
        output_dir_path = input_path.parent / output_dir_name
        print(f"Output will be saved in: {output_dir_path}")
        os.makedirs(output_dir_path, exist_ok=True) # Create the main output dir for the batch

        # Find image files (adjust extensions as needed)
        image_extensions = ["*.jpg", "*.jpeg", "*.png", "*.webp", "*.bmp"]
        image_files = []
        for ext in image_extensions:
            image_files.extend(input_path.glob(ext))

        if not image_files:
            print(f"No image files found in directory: {input_path}")
            return

        total_panels_extracted = 0
        num_files_processed = 0

        for img_file_path in sorted(image_files): # Process in sorted order
             # Output for each image's panels goes directly into the derived output_dir_path
             try:
                 num_panels = process_image(
                    str(img_file_path),
                    output_dir=str(output_dir_path), # Panels saved directly here
                    visualize=args.visualize,
                    edge_detection_sigma=args.edge_sigma,
                    low_threshold=args.low_threshold,
                    high_threshold=args.high_threshold,
                    min_panel_area=args.min_panel_area,
                    reading_order=args.reading_order
                 )
                 total_panels_extracted += num_panels
                 num_files_processed += 1
             except Exception as e:
                 print(f"!! Failed to process {img_file_path}: {e}")
                 # Optionally add more robust error handling here (e.g., log errors)

        print("\n" + "="*30)
        print("Batch Processing Summary:")
        print(f" Processed {num_files_processed} image files from '{input_path}'.")
        print(f" Total panels extracted: {total_panels_extracted}")
        print(f" Output saved in: '{output_dir_path}'")
        print("="*30)

    elif input_path.is_file():
        # --- Single File Processing ---
        print(f"Processing single file: {input_path}")

        # Determine output directory for single file
        if output_base is None:
            # If --output is not specified, create output dir next to the file
            output_dir_path = input_path.parent / f"{input_path.stem}_output"
        else:
            output_dir_path = Path(output_base)

        print(f"Output will be saved in: {output_dir_path}")
        os.makedirs(output_dir_path, exist_ok=True)

        try:
            num_panels = process_image(
                str(input_path),
                output_dir=str(output_dir_path),
                visualize=args.visualize,
                edge_detection_sigma=args.edge_sigma,
                low_threshold=args.low_threshold,
                high_threshold=args.high_threshold,
                min_panel_area=args.min_panel_area,
                reading_order=args.reading_order
            )
            print(f"\nFinished processing. Extracted {num_panels} panels.")
        except Exception as e:
            print(f"!! Failed to process {input_path}: {e}")

    else:
        print(f"Error: Input path is neither a file nor a directory: {args.input_path}")


if __name__ == "__main__":
    main()