feat: Add RTL reading order and robust panel sorting
Browse filesImplement a right-to-left (RTL) reading order option for manga.
- app.py +12 -2
- image_processing/panel.py +87 -2
app.py
CHANGED
|
@@ -30,6 +30,7 @@ def process_images(
|
|
| 30 |
input_files,
|
| 31 |
method,
|
| 32 |
separate_folders,
|
|
|
|
| 33 |
# Traditional method params
|
| 34 |
merge_mode,
|
| 35 |
split_joint,
|
|
@@ -69,12 +70,14 @@ def process_images(
|
|
| 69 |
split_joint_panels=split_joint,
|
| 70 |
fallback=fallback,
|
| 71 |
mode=output_mode,
|
| 72 |
-
merge=merge_mode
|
|
|
|
| 73 |
)
|
| 74 |
elif method == "AI":
|
| 75 |
panel_blocks = generate_panel_blocks_by_ai(
|
| 76 |
image=image,
|
| 77 |
-
merge=merge_mode
|
|
|
|
| 78 |
)
|
| 79 |
else:
|
| 80 |
# Should not happen with Radio button selection
|
|
@@ -168,6 +171,12 @@ def main():
|
|
| 168 |
info="If unchecked, all panels will be in the root of the ZIP, with filenames prefixed by the original image name."
|
| 169 |
)
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
# --- Shared Parameters ---
|
| 172 |
gr.Markdown("### Shared Parameters")
|
| 173 |
merge_mode = gr.Dropdown(
|
|
@@ -219,6 +228,7 @@ def main():
|
|
| 219 |
input_files,
|
| 220 |
method,
|
| 221 |
separate_folders,
|
|
|
|
| 222 |
merge_mode,
|
| 223 |
split_joint,
|
| 224 |
fallback,
|
|
|
|
| 30 |
input_files,
|
| 31 |
method,
|
| 32 |
separate_folders,
|
| 33 |
+
rtl_order,
|
| 34 |
# Traditional method params
|
| 35 |
merge_mode,
|
| 36 |
split_joint,
|
|
|
|
| 70 |
split_joint_panels=split_joint,
|
| 71 |
fallback=fallback,
|
| 72 |
mode=output_mode,
|
| 73 |
+
merge=merge_mode,
|
| 74 |
+
rtl_order=rtl_order
|
| 75 |
)
|
| 76 |
elif method == "AI":
|
| 77 |
panel_blocks = generate_panel_blocks_by_ai(
|
| 78 |
image=image,
|
| 79 |
+
merge=merge_mode,
|
| 80 |
+
rtl_order=rtl_order
|
| 81 |
)
|
| 82 |
else:
|
| 83 |
# Should not happen with Radio button selection
|
|
|
|
| 171 |
info="If unchecked, all panels will be in the root of the ZIP, with filenames prefixed by the original image name."
|
| 172 |
)
|
| 173 |
|
| 174 |
+
rtl_order = gr.Checkbox(
|
| 175 |
+
label="Right-to-Left (RTL) Reading Order",
|
| 176 |
+
value=True, # Default to True for manga
|
| 177 |
+
info="Check this for manga that is read from right to left. Uncheck for western comics."
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
# --- Shared Parameters ---
|
| 181 |
gr.Markdown("### Shared Parameters")
|
| 182 |
merge_mode = gr.Dropdown(
|
|
|
|
| 228 |
input_files,
|
| 229 |
method,
|
| 230 |
separate_folders,
|
| 231 |
+
rtl_order,
|
| 232 |
merge_mode,
|
| 233 |
split_joint,
|
| 234 |
fallback,
|
image_processing/panel.py
CHANGED
|
@@ -308,6 +308,71 @@ def get_fallback_panels(
|
|
| 308 |
|
| 309 |
return panels
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
def generate_panel_blocks(
|
| 313 |
image: np.ndarray,
|
|
@@ -315,7 +380,8 @@ def generate_panel_blocks(
|
|
| 315 |
split_joint_panels: bool = False,
|
| 316 |
fallback: bool = True,
|
| 317 |
mode: str = OutputMode.BOUNDING,
|
| 318 |
-
merge: str = MergeMode.NONE
|
|
|
|
| 319 |
) -> list[np.ndarray]:
|
| 320 |
"""
|
| 321 |
Generates the separate panel images from the base image
|
|
@@ -324,6 +390,7 @@ def generate_panel_blocks(
|
|
| 324 |
- mode: The mode to use for extraction
|
| 325 |
- 'masked': Extracts the panels by cuting out only the inside of the contours
|
| 326 |
- 'bounding': Extracts the panels by using the bounding boxes of the contours
|
|
|
|
| 327 |
"""
|
| 328 |
|
| 329 |
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
@@ -332,6 +399,12 @@ def generate_panel_blocks(
|
|
| 332 |
page_without_background = get_page_without_background(grayscale_image, background_mask, split_joint_panels)
|
| 333 |
contours, _ = cv2.findContours(page_without_background, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 334 |
contours = list(filter(lambda c: is_contour_sufficiently_big(c, image.shape[0], image.shape[1]), contours))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
def get_panels(contours):
|
| 337 |
panels = extract_panels(image, contours, mode=mode)
|
|
@@ -353,9 +426,16 @@ def generate_panel_blocks(
|
|
| 353 |
return panels
|
| 354 |
|
| 355 |
|
| 356 |
-
def generate_panel_blocks_by_ai(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
"""
|
| 358 |
Generates the separate panel images from the base image using AI with merge
|
|
|
|
|
|
|
|
|
|
| 359 |
"""
|
| 360 |
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 361 |
processed_image = preprocess_image(grayscale_image)
|
|
@@ -369,6 +449,11 @@ def generate_panel_blocks_by_ai(image: np.ndarray, merge: str = MergeMode.NONE)
|
|
| 369 |
x1, y1, x2, y2, conf, cls = detection.tolist() # Convert to Python list
|
| 370 |
x1, y1, x2, y2 = map(int, [x1, y1, x2, y2])
|
| 371 |
bounding_boxes.append((x1, y1, x2 - x1, y2 - y1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
def get_panels(bounding_boxes):
|
| 374 |
panels = []
|
|
|
|
| 308 |
|
| 309 |
return panels
|
| 310 |
|
| 311 |
+
def _sort_items_by_reading_order(items: list, rtl_order: bool, image_height: int) -> list:
|
| 312 |
+
"""
|
| 313 |
+
Sorts contours or bounding boxes based on reading order (top-to-bottom, then LTR/RTL).
|
| 314 |
+
This function is robust against minor vertical misalignments by grouping items into rows.
|
| 315 |
+
|
| 316 |
+
Parameters:
|
| 317 |
+
- items: A list of contours (np.ndarray) or bounding boxes (tuple of x,y,w,h).
|
| 318 |
+
- rtl_order: If True, sort horizontally from right-to-left.
|
| 319 |
+
- image_height: The height of the original image, used for calculating tolerance.
|
| 320 |
+
|
| 321 |
+
Returns:
|
| 322 |
+
- A sorted list of the input items.
|
| 323 |
+
"""
|
| 324 |
+
if not items:
|
| 325 |
+
return []
|
| 326 |
+
|
| 327 |
+
# Unify items into a list of (item, bbox) tuples for consistent processing
|
| 328 |
+
item_bboxes = []
|
| 329 |
+
for item in items:
|
| 330 |
+
if isinstance(item, np.ndarray): # It's a contour
|
| 331 |
+
bbox = cv2.boundingRect(item)
|
| 332 |
+
else: # It's already a bbox tuple
|
| 333 |
+
bbox = item
|
| 334 |
+
item_bboxes.append((item, bbox))
|
| 335 |
+
|
| 336 |
+
# Initial sort by top y-coordinate
|
| 337 |
+
item_bboxes.sort(key=lambda x: x[1][1])
|
| 338 |
+
|
| 339 |
+
rows = []
|
| 340 |
+
current_row = []
|
| 341 |
+
if item_bboxes:
|
| 342 |
+
# Start the first row
|
| 343 |
+
current_row.append(item_bboxes[0])
|
| 344 |
+
first_item_in_row_bbox = item_bboxes[0][1]
|
| 345 |
+
|
| 346 |
+
# Define a dynamic tolerance based on the height of the first panel in a row.
|
| 347 |
+
# A panel can be considered in the same row if its top is not lower than
|
| 348 |
+
# the first panel's top + 30% of its height. This is a robust heuristic.
|
| 349 |
+
# We also add a minimum tolerance for very short panels.
|
| 350 |
+
y_tolerance = max(10, int(first_item_in_row_bbox[3] * 0.3))
|
| 351 |
+
|
| 352 |
+
for item, bbox in item_bboxes[1:]:
|
| 353 |
+
# If the current panel's y is within the tolerance of the current row's start y
|
| 354 |
+
if bbox[1] < first_item_in_row_bbox[1] + y_tolerance:
|
| 355 |
+
current_row.append((item, bbox))
|
| 356 |
+
else:
|
| 357 |
+
# Finish the current row
|
| 358 |
+
# Sort the completed row horizontally
|
| 359 |
+
current_row.sort(key=lambda x: -x[1][0] if rtl_order else x[1][0])
|
| 360 |
+
rows.append(current_row)
|
| 361 |
+
|
| 362 |
+
# Start a new row
|
| 363 |
+
current_row = [(item, bbox)]
|
| 364 |
+
first_item_in_row_bbox = bbox
|
| 365 |
+
y_tolerance = max(10, int(first_item_in_row_bbox[3] * 0.3))
|
| 366 |
+
|
| 367 |
+
# Add the last processed row
|
| 368 |
+
if current_row:
|
| 369 |
+
current_row.sort(key=lambda x: -x[1][0] if rtl_order else x[1][0])
|
| 370 |
+
rows.append(current_row)
|
| 371 |
+
|
| 372 |
+
# Flatten the rows and extract the original items in the correct order
|
| 373 |
+
sorted_items = [item for row in rows for item, bbox in row]
|
| 374 |
+
|
| 375 |
+
return sorted_items
|
| 376 |
|
| 377 |
def generate_panel_blocks(
|
| 378 |
image: np.ndarray,
|
|
|
|
| 380 |
split_joint_panels: bool = False,
|
| 381 |
fallback: bool = True,
|
| 382 |
mode: str = OutputMode.BOUNDING,
|
| 383 |
+
merge: str = MergeMode.NONE,
|
| 384 |
+
rtl_order: bool = False
|
| 385 |
) -> list[np.ndarray]:
|
| 386 |
"""
|
| 387 |
Generates the separate panel images from the base image
|
|
|
|
| 390 |
- mode: The mode to use for extraction
|
| 391 |
- 'masked': Extracts the panels by cuting out only the inside of the contours
|
| 392 |
- 'bounding': Extracts the panels by using the bounding boxes of the contours
|
| 393 |
+
- rtl_order: If True, sort panels from right-to-left. Otherwise, left-to-right.
|
| 394 |
"""
|
| 395 |
|
| 396 |
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
|
|
| 399 |
page_without_background = get_page_without_background(grayscale_image, background_mask, split_joint_panels)
|
| 400 |
contours, _ = cv2.findContours(page_without_background, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 401 |
contours = list(filter(lambda c: is_contour_sufficiently_big(c, image.shape[0], image.shape[1]), contours))
|
| 402 |
+
|
| 403 |
+
# Sort by top-to-bottom (y-coordinate) first, then by horizontal order.
|
| 404 |
+
# For RTL, we sort by x-coordinate in descending order (by negating it).
|
| 405 |
+
if contours:
|
| 406 |
+
image_height = image.shape[0]
|
| 407 |
+
contours = _sort_items_by_reading_order(contours, rtl_order, image_height)
|
| 408 |
|
| 409 |
def get_panels(contours):
|
| 410 |
panels = extract_panels(image, contours, mode=mode)
|
|
|
|
| 426 |
return panels
|
| 427 |
|
| 428 |
|
| 429 |
+
def generate_panel_blocks_by_ai(
|
| 430 |
+
image: np.ndarray,
|
| 431 |
+
merge: str = MergeMode.NONE,
|
| 432 |
+
rtl_order: bool = False
|
| 433 |
+
) -> list[np.ndarray]:
|
| 434 |
"""
|
| 435 |
Generates the separate panel images from the base image using AI with merge
|
| 436 |
+
|
| 437 |
+
Parameters:
|
| 438 |
+
- rtl_order: If True, sort panels from right-to-left. Otherwise, left-to-right.
|
| 439 |
"""
|
| 440 |
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 441 |
processed_image = preprocess_image(grayscale_image)
|
|
|
|
| 449 |
x1, y1, x2, y2, conf, cls = detection.tolist() # Convert to Python list
|
| 450 |
x1, y1, x2, y2 = map(int, [x1, y1, x2, y2])
|
| 451 |
bounding_boxes.append((x1, y1, x2 - x1, y2 - y1))
|
| 452 |
+
|
| 453 |
+
# Bounding boxes are already (x, y, w, h), so we access coordinates directly.
|
| 454 |
+
if bounding_boxes:
|
| 455 |
+
image_height = image.shape[0]
|
| 456 |
+
bounding_boxes = _sort_items_by_reading_order(bounding_boxes, rtl_order, image_height)
|
| 457 |
|
| 458 |
def get_panels(bounding_boxes):
|
| 459 |
panels = []
|