|
|
| """
|
| Text Addition Module
|
| ===================
|
|
|
| Handles the insertion of translated text into processed speech bubbles.
|
| This module provides intelligent text fitting, centering, and formatting
|
| to ensure the translated text fits properly within bubble boundaries.
|
|
|
| Features:
|
| - Automatic text wrapping and sizing
|
| - Smart font size adjustment
|
| - Text centering (horizontal and vertical)
|
| - Multi-line text support
|
|
|
| Author: MangaTranslator Team
|
| License: MIT
|
| """
|
|
|
| from PIL import Image, ImageDraw, ImageFont
|
| import numpy as np
|
| import textwrap
|
| import cv2
|
|
|
|
|
| def add_text(image, text, font_path, bubble_contour):
|
| """
|
| Add translated text inside a speech bubble with automatic sizing and centering
|
|
|
| This function intelligently places translated text within the bubble boundaries,
|
| automatically adjusting font size and text wrapping to ensure optimal fit.
|
|
|
| Args:
|
| image (numpy.ndarray): Processed bubble image in BGR format (OpenCV)
|
| text (str): Translated text to place inside the speech bubble
|
| font_path (str): Path to the font file (.ttf format)
|
| bubble_contour (numpy.ndarray): Contour defining the speech bubble boundary
|
|
|
| Returns:
|
| numpy.ndarray: Image with translated text properly placed inside the bubble
|
| """
|
|
|
| pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
| draw = ImageDraw.Draw(pil_image)
|
|
|
|
|
| x, y, w, h = cv2.boundingRect(bubble_contour)
|
| bubble_area = w * h
|
|
|
| print(f"📏 Bubble dimensions: {w}x{h} (area: {bubble_area}px²)")
|
|
|
|
|
|
|
| aspect_ratio = w / h if h > 0 else 1
|
| min_dimension = min(w, h)
|
| max_dimension = max(w, h)
|
|
|
|
|
| if aspect_ratio < 0.3:
|
| base_font_size = max(24, min(80, int(min_dimension * 0.32)))
|
| bubble_type = "🔺 Extremely Vertical"
|
| boost_factor = 1.53
|
| elif aspect_ratio < 0.5:
|
| base_font_size = max(22, min(75, int(min_dimension * 0.28)))
|
| bubble_type = "🔺 Very Vertical"
|
| boost_factor = 1.395
|
| elif aspect_ratio < 0.7:
|
| base_font_size = max(20, min(72, int(min_dimension * 0.25)))
|
| bubble_type = "🔺 Vertical"
|
| boost_factor = 1.26
|
| elif aspect_ratio > 3.0:
|
| base_font_size = max(14, min(50, int(h * 0.60)))
|
| bubble_type = "🔸 Extremely Wide"
|
| boost_factor = 0.81
|
| elif aspect_ratio > 2.2:
|
| base_font_size = max(16, min(55, int(h * 0.65)))
|
| bubble_type = "🔸 Very Wide"
|
| boost_factor = 0.9
|
| elif aspect_ratio > 1.6:
|
| base_font_size = max(18, min(60, int(h * 0.70)))
|
| bubble_type = "🔸 Wide"
|
| boost_factor = 0.99
|
| else:
|
| base_font_size = max(22, min(72, int(np.sqrt(bubble_area) * 0.18)))
|
| bubble_type = "🔷 Balanced"
|
| boost_factor = 1.08
|
|
|
|
|
| base_font_size = int(base_font_size * boost_factor)
|
|
|
| print(f"{bubble_type} bubble detected (ratio: {aspect_ratio:.2f})")
|
| print(f"🎨 Initial font size: {base_font_size}px (boost: {boost_factor}x)")
|
|
|
|
|
|
|
| target_width = int(w * 0.88)
|
| target_height = int(h * 0.88)
|
| line_spacing_ratio = 1.05
|
| min_font_size = 16
|
|
|
| print(f"🎯 Target area: {target_width}x{target_height} ({target_width * target_height}px²)")
|
|
|
|
|
| best_font_size = min_font_size
|
| best_fit = None
|
|
|
|
|
| max_test_font = min(120, max(target_width // 4, target_height // 2))
|
|
|
| for test_font in range(max_test_font, min_font_size - 1, -1):
|
| font = ImageFont.truetype(font_path, size=test_font)
|
| line_height = int(test_font * line_spacing_ratio)
|
|
|
|
|
|
|
| if test_font >= 40:
|
| char_width = test_font * 0.55
|
| elif test_font >= 20:
|
| char_width = test_font * 0.58
|
| else:
|
| char_width = test_font * 0.62
|
|
|
| chars_per_line = max(5, int(target_width / char_width))
|
|
|
|
|
| wrapped_text = textwrap.fill(text, width=chars_per_line,
|
| break_long_words=True, break_on_hyphens=True)
|
| lines = wrapped_text.split('\n')
|
|
|
|
|
| max_line_width = 0
|
| for line in lines:
|
| if line.strip():
|
| line_width = draw.textlength(line, font=font)
|
| max_line_width = max(max_line_width, line_width)
|
|
|
| total_text_height = len(lines) * line_height
|
|
|
|
|
| fits_width = max_line_width <= target_width
|
| fits_height = total_text_height <= target_height
|
|
|
| if fits_width and fits_height:
|
|
|
| if best_fit is None or test_font > best_font_size:
|
| best_font_size = test_font
|
| best_fit = {
|
| 'font': font,
|
| 'font_size': test_font,
|
| 'line_height': line_height,
|
| 'wrapped_text': wrapped_text,
|
| 'lines': lines,
|
| 'max_line_width': max_line_width,
|
| 'total_height': total_text_height,
|
| 'chars_per_line': chars_per_line
|
| }
|
| print(f" ✅ Font {test_font}px: {max_line_width:.0f}x{total_text_height} fits - NEW BEST!")
|
| else:
|
| print(f" ✅ Font {test_font}px: {max_line_width:.0f}x{total_text_height} fits")
|
|
|
| else:
|
| print(f" ❌ Font {test_font}px: {max_line_width:.0f}x{total_text_height} too big for {target_width}x{target_height}")
|
|
|
|
|
|
|
| if best_fit:
|
| font = best_fit['font']
|
| line_height = best_fit['line_height']
|
| wrapped_text = best_fit['wrapped_text']
|
| lines = best_fit['lines']
|
| total_text_height = best_fit['total_height']
|
| print(f"🎯 OPTIMAL: Using {best_font_size}px font (LARGEST that fits) with {len(lines)} lines")
|
| else:
|
|
|
| font_size = min_font_size
|
| font = ImageFont.truetype(font_path, size=font_size)
|
| line_height = int(font_size * line_spacing_ratio)
|
| char_width = font_size * 0.6
|
| chars_per_line = max(5, int(target_width / char_width))
|
| wrapped_text = textwrap.fill(text, width=chars_per_line,
|
| break_long_words=True, break_on_hyphens=True)
|
| lines = wrapped_text.split('\n')
|
| total_text_height = len(lines) * line_height
|
| print(f"⚠️ FALLBACK: Using minimum {min_font_size}px font")
|
|
|
|
|
|
|
|
|
| bubble_center_y = y + h // 2
|
| text_center_offset = total_text_height // 2
|
| text_y = bubble_center_y - text_center_offset
|
|
|
|
|
| text_y = max(text_y, y + 5)
|
|
|
| print(f"🎯 Final text placement: {len(lines)} lines, font={font.size}px, "
|
| f"bubble_center=({x + w//2}, {y + h//2}), text_start=({x + w//2}, {text_y})")
|
|
|
|
|
| current_y = text_y
|
| for i, line in enumerate(lines):
|
| if not line.strip():
|
| current_y += line_height
|
| continue
|
|
|
|
|
| text_bbox = draw.textbbox((0, 0), line, font=font)
|
| text_width = text_bbox[2] - text_bbox[0]
|
|
|
|
|
| text_x = x + (w - text_width) // 2
|
|
|
|
|
| outline_width = max(1, font.size // 16)
|
|
|
| if outline_width > 0:
|
|
|
| for dx in [-outline_width, 0, outline_width]:
|
| for dy in [-outline_width, 0, outline_width]:
|
| if dx != 0 or dy != 0:
|
| draw.text((text_x + dx, current_y + dy), line,
|
| font=font, fill=(255, 255, 255))
|
|
|
|
|
| draw.text((text_x, current_y), line, font=font, fill=(0, 0, 0))
|
|
|
|
|
| current_y += line_height
|
|
|
|
|
| image[:, :, :] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
|
|
| return image
|
|
|