haaaaus commited on
Commit
231b5a2
·
verified ·
1 Parent(s): 7ac9fc5

Upload 56 files

Browse files
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. Dockerfile +59 -59
  3. __pycache__/add_text.cpython-311.pyc +0 -0
  4. __pycache__/app.cpython-311.pyc +0 -0
  5. __pycache__/detect_bubbles.cpython-311.pyc +0 -0
  6. __pycache__/process_bubble.cpython-311.pyc +0 -0
  7. add_text.py +97 -27
  8. demo/add_text.py +65 -0
  9. demo/app.py +72 -0
  10. demo/detect_bubbles.py +19 -0
  11. demo/examples/0.png +3 -0
  12. demo/examples/ex0.png +3 -0
  13. demo/fonts/animeace_i.ttf +0 -0
  14. demo/fonts/ariali.ttf +3 -0
  15. demo/fonts/mangati.ttf +0 -0
  16. demo/model.pt +3 -0
  17. demo/packages.txt +1 -0
  18. demo/process_bubble.py +27 -0
  19. demo/requirements.txt +10 -0
  20. demo/translator.py +70 -0
  21. examples/0.png +3 -0
  22. examples/1.png +3 -0
  23. examples/2.png +3 -0
  24. examples/3.png +3 -0
  25. examples/ex0.png +3 -0
  26. examples/ex1.png +3 -0
  27. examples/ex2.png +3 -0
  28. examples/ex3.png +3 -0
  29. fonts/animeace_i.ttf +0 -0
  30. fonts/ariali.ttf +3 -0
  31. fonts/mangati.ttf +0 -0
  32. model/model.pt +3 -0
  33. model/model_training.ipynb +100 -0
  34. ocr/__init__.py +4 -0
  35. ocr/__pycache__/__init__.cpython-311.pyc +0 -0
  36. ocr/__pycache__/chrome_lens_ocr.cpython-311.pyc +0 -0
  37. ocr/chrome_lens_ocr.py +116 -0
  38. static/css/style.css +346 -0
  39. static/img/header.png +0 -0
  40. static/img/loading.gif +3 -0
  41. static/img/logo.png +0 -0
  42. static/js/app.js +124 -0
  43. templates/index.html +161 -0
  44. templates/translate.html +69 -0
  45. translator/__init__.py +5 -0
  46. translator/__pycache__/gemini_translator.cpython-311.pyc +0 -0
  47. translator/__pycache__/translator.cpython-311.pyc +0 -0
  48. translator/gemini_translator.py +288 -0
  49. translator/test_translator.py +23 -0
  50. translator/translator.py +172 -0
.gitattributes CHANGED
@@ -33,3 +33,16 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ demo/examples/0.png filter=lfs diff=lfs merge=lfs -text
37
+ demo/examples/ex0.png filter=lfs diff=lfs merge=lfs -text
38
+ demo/fonts/ariali.ttf filter=lfs diff=lfs merge=lfs -text
39
+ examples/0.png filter=lfs diff=lfs merge=lfs -text
40
+ examples/1.png filter=lfs diff=lfs merge=lfs -text
41
+ examples/2.png filter=lfs diff=lfs merge=lfs -text
42
+ examples/3.png filter=lfs diff=lfs merge=lfs -text
43
+ examples/ex0.png filter=lfs diff=lfs merge=lfs -text
44
+ examples/ex1.png filter=lfs diff=lfs merge=lfs -text
45
+ examples/ex2.png filter=lfs diff=lfs merge=lfs -text
46
+ examples/ex3.png filter=lfs diff=lfs merge=lfs -text
47
+ fonts/ariali.ttf filter=lfs diff=lfs merge=lfs -text
48
+ static/img/loading.gif filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -1,59 +1,59 @@
1
- # Manga Translator - HuggingFace Spaces Dockerfile
2
- # Uses Python 3.10 with CUDA support for YOLO model
3
-
4
- FROM python:3.10-slim
5
-
6
- # Set environment variables
7
- ENV PYTHONUNBUFFERED=1 \
8
- PYTHONDONTWRITEBYTECODE=1 \
9
- PIP_NO_CACHE_DIR=1 \
10
- PIP_DISABLE_PIP_VERSION_CHECK=1
11
-
12
- # HuggingFace Spaces specific settings
13
- ENV GRADIO_SERVER_NAME="0.0.0.0" \
14
- GRADIO_SERVER_PORT=7860
15
-
16
- # Install system dependencies
17
- RUN apt-get update && apt-get install -y --no-install-recommends \
18
- git \
19
- libgl1 \
20
- libglib2.0-0 \
21
- libsm6 \
22
- libxext6 \
23
- libxrender-dev \
24
- libgomp1 \
25
- wget \
26
- curl \
27
- fonts-dejavu-core \
28
- && rm -rf /var/lib/apt/lists/*
29
-
30
- # Create app user (HuggingFace Spaces requirement)
31
- RUN useradd -m -u 1000 user
32
- WORKDIR /app
33
-
34
- # Copy requirements first for better caching
35
- COPY --chown=user requirements.txt .
36
-
37
- # Install Python dependencies
38
- RUN pip install --upgrade pip && \
39
- pip install -r requirements.txt
40
-
41
- # Copy application files
42
- COPY --chown=user . .
43
-
44
- # Create necessary directories with proper permissions
45
- RUN mkdir -p /app/uploads /app/outputs && \
46
- chown -R user:user /app
47
-
48
- # Switch to non-root user
49
- USER user
50
-
51
- # Expose port for HuggingFace Spaces
52
- EXPOSE 7860
53
-
54
- # Health check
55
- HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
56
- CMD curl -f http://localhost:7860/ || exit 1
57
-
58
- # Run with gunicorn for production
59
- CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
 
1
+ # Manga Translator - HuggingFace Spaces Dockerfile
2
+ # Uses Python 3.10 with CUDA support for YOLO model
3
+
4
+ FROM python:3.10-slim
5
+
6
+ # Set environment variables
7
+ ENV PYTHONUNBUFFERED=1 \
8
+ PYTHONDONTWRITEBYTECODE=1 \
9
+ PIP_NO_CACHE_DIR=1 \
10
+ PIP_DISABLE_PIP_VERSION_CHECK=1
11
+
12
+ # HuggingFace Spaces specific settings
13
+ ENV GRADIO_SERVER_NAME="0.0.0.0" \
14
+ GRADIO_SERVER_PORT=7860
15
+
16
+ # Install system dependencies
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ git \
19
+ libgl1 \
20
+ libglib2.0-0 \
21
+ libsm6 \
22
+ libxext6 \
23
+ libxrender-dev \
24
+ libgomp1 \
25
+ wget \
26
+ curl \
27
+ fonts-dejavu-core \
28
+ && rm -rf /var/lib/apt/lists/*
29
+
30
+ # Create app user (HuggingFace Spaces requirement)
31
+ RUN useradd -m -u 1000 user
32
+ WORKDIR /app
33
+
34
+ # Copy requirements first for better caching
35
+ COPY --chown=user requirements.txt .
36
+
37
+ # Install Python dependencies
38
+ RUN pip install --upgrade pip && \
39
+ pip install -r requirements.txt
40
+
41
+ # Copy application files
42
+ COPY --chown=user . .
43
+
44
+ # Create necessary directories with proper permissions
45
+ RUN mkdir -p /app/uploads /app/outputs && \
46
+ chown -R user:user /app
47
+
48
+ # Switch to non-root user
49
+ USER user
50
+
51
+ # Expose port for HuggingFace Spaces
52
+ EXPOSE 7860
53
+
54
+ # Health check
55
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
56
+ CMD curl -f http://localhost:7860/ || exit 1
57
+
58
+ # Run with gunicorn for production
59
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
__pycache__/add_text.cpython-311.pyc ADDED
Binary file (5.39 kB). View file
 
__pycache__/app.cpython-311.pyc ADDED
Binary file (7.15 kB). View file
 
__pycache__/detect_bubbles.cpython-311.pyc ADDED
Binary file (9.95 kB). View file
 
__pycache__/process_bubble.cpython-311.pyc ADDED
Binary file (1.5 kB). View file
 
add_text.py CHANGED
@@ -2,22 +2,103 @@ from PIL import Image, ImageDraw, ImageFont
2
  import numpy as np
3
  import textwrap
4
  import cv2
 
5
 
6
  # Font cache to avoid reloading fonts from disk
7
  _font_cache = {}
8
 
 
 
 
 
 
9
 
10
  def get_cached_font(font_path, size):
11
  """Get font from cache or load it."""
12
  cache_key = (font_path, size)
13
  if cache_key not in _font_cache:
14
- _font_cache[cache_key] = ImageFont.truetype(font_path, size=size)
 
 
 
 
15
  return _font_cache[cache_key]
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def add_text(image, text, font_path, bubble_contour):
19
  """
20
- Add text inside a speech bubble contour.
21
 
22
  Args:
23
  image (numpy.ndarray): Processed bubble image (cv2 format - BGR).
@@ -28,49 +109,38 @@ def add_text(image, text, font_path, bubble_contour):
28
  Returns:
29
  numpy.ndarray: Image with text placed inside the speech bubble.
30
  """
 
 
 
31
  pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
32
  draw = ImageDraw.Draw(pil_image)
33
 
34
  x, y, w, h = cv2.boundingRect(bubble_contour)
35
-
36
- line_height = 16
37
- font_size = 14
38
- wrapping_ratio = 0.075
39
-
40
- wrapped_text = textwrap.fill(text, width=int(w * wrapping_ratio),
41
- break_long_words=True)
42
 
43
- font = get_cached_font(font_path, font_size)
44
-
 
 
 
45
  lines = wrapped_text.split('\n')
46
- total_text_height = (len(lines)) * line_height
47
-
48
- while total_text_height > h:
49
- line_height -= 2
50
- font_size -= 2
51
- wrapping_ratio += 0.025
52
-
53
- wrapped_text = textwrap.fill(text, width=int(w * wrapping_ratio),
54
- break_long_words=True)
55
-
56
- font = get_cached_font(font_path, font_size)
57
-
58
- lines = wrapped_text.split('\n')
59
- total_text_height = (len(lines)) * line_height
60
 
61
  # Vertical centering
62
  text_y = y + (h - total_text_height) // 2
63
 
64
  for line in lines:
65
- text_length = draw.textlength(line, font=font)
 
 
 
66
 
67
  # Horizontal centering
68
  text_x = x + (w - text_length) // 2
69
 
70
  draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0))
71
-
72
  text_y += line_height
73
 
74
  image[:, :, :] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
75
 
76
  return image
 
 
2
  import numpy as np
3
  import textwrap
4
  import cv2
5
+ import math
6
 
7
  # Font cache to avoid reloading fonts from disk
8
  _font_cache = {}
9
 
10
+ # Font sizing configuration
11
+ MIN_FONT_SIZE = 10
12
+ MAX_FONT_SIZE = 60
13
+ PADDING_RATIO = 0.1 # 10% padding inside bubble
14
+
15
 
16
  def get_cached_font(font_path, size):
17
  """Get font from cache or load it."""
18
  cache_key = (font_path, size)
19
  if cache_key not in _font_cache:
20
+ try:
21
+ _font_cache[cache_key] = ImageFont.truetype(font_path, size=size)
22
+ except:
23
+ # Fallback to default font if custom font fails
24
+ _font_cache[cache_key] = ImageFont.load_default()
25
  return _font_cache[cache_key]
26
 
27
 
28
+ def calculate_optimal_font_size(text, w, h, font_path):
29
+ """
30
+ Calculate optimal font size to fill the bubble nicely.
31
+
32
+ Args:
33
+ text: Text to render
34
+ w: Bubble width
35
+ h: Bubble height
36
+ font_path: Path to font file
37
+
38
+ Returns:
39
+ tuple: (font_size, line_height, wrapped_text, font)
40
+ """
41
+ # Apply padding
42
+ usable_w = int(w * (1 - 2 * PADDING_RATIO))
43
+ usable_h = int(h * (1 - 2 * PADDING_RATIO))
44
+
45
+ if usable_w <= 0 or usable_h <= 0:
46
+ return MIN_FONT_SIZE, MIN_FONT_SIZE, text, get_cached_font(font_path, MIN_FONT_SIZE)
47
+
48
+ # Estimate initial font size based on bubble area and text length
49
+ bubble_area = usable_w * usable_h
50
+ char_count = max(len(text), 1)
51
+
52
+ # Each character needs approximately (font_size * 0.6) * (font_size * 1.2) pixels
53
+ # So font_size^2 * 0.72 ≈ area / char_count
54
+ estimated_size = int(math.sqrt(bubble_area / (char_count * 0.8)))
55
+
56
+ # Clamp to reasonable range
57
+ font_size = max(MIN_FONT_SIZE, min(MAX_FONT_SIZE, estimated_size))
58
+
59
+ # Binary search for optimal font size
60
+ best_font_size = MIN_FONT_SIZE
61
+ best_wrapped = text
62
+
63
+ for size in range(font_size, MIN_FONT_SIZE - 1, -2):
64
+ font = get_cached_font(font_path, size)
65
+ line_height = int(size * 1.3)
66
+
67
+ # Calculate characters per line based on font size
68
+ avg_char_width = size * 0.6 # Approximate average character width
69
+ chars_per_line = max(1, int(usable_w / avg_char_width))
70
+
71
+ # Wrap text
72
+ wrapped = textwrap.fill(text, width=chars_per_line, break_long_words=True)
73
+ lines = wrapped.split('\n')
74
+
75
+ # Calculate total height needed
76
+ total_height = len(lines) * line_height
77
+
78
+ # Check if text fits
79
+ if total_height <= usable_h:
80
+ # Check if all lines fit width-wise
81
+ fits_width = True
82
+ for line in lines:
83
+ try:
84
+ line_width = font.getlength(line)
85
+ except:
86
+ line_width = len(line) * avg_char_width
87
+ if line_width > usable_w:
88
+ fits_width = False
89
+ break
90
+
91
+ if fits_width:
92
+ best_font_size = size
93
+ best_wrapped = wrapped
94
+ break
95
+
96
+ return best_font_size, int(best_font_size * 1.3), best_wrapped, get_cached_font(font_path, best_font_size)
97
+
98
+
99
  def add_text(image, text, font_path, bubble_contour):
100
  """
101
+ Add text inside a speech bubble contour with dynamic font sizing.
102
 
103
  Args:
104
  image (numpy.ndarray): Processed bubble image (cv2 format - BGR).
 
109
  Returns:
110
  numpy.ndarray: Image with text placed inside the speech bubble.
111
  """
112
+ if not text or not text.strip():
113
+ return image
114
+
115
  pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
116
  draw = ImageDraw.Draw(pil_image)
117
 
118
  x, y, w, h = cv2.boundingRect(bubble_contour)
 
 
 
 
 
 
 
119
 
120
+ # Calculate optimal font size
121
+ font_size, line_height, wrapped_text, font = calculate_optimal_font_size(
122
+ text, w, h, font_path
123
+ )
124
+
125
  lines = wrapped_text.split('\n')
126
+ total_text_height = len(lines) * line_height
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
  # Vertical centering
129
  text_y = y + (h - total_text_height) // 2
130
 
131
  for line in lines:
132
+ try:
133
+ text_length = font.getlength(line)
134
+ except:
135
+ text_length = len(line) * font_size * 0.6
136
 
137
  # Horizontal centering
138
  text_x = x + (w - text_length) // 2
139
 
140
  draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0))
 
141
  text_y += line_height
142
 
143
  image[:, :, :] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
144
 
145
  return image
146
+
demo/add_text.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+ import numpy as np
3
+ import textwrap
4
+ import cv2
5
+
6
+
7
+ def add_text(image, text, font_path, bubble_contour):
8
+ """
9
+ Add text inside a speech bubble contour.
10
+
11
+ Args:
12
+ image (numpy.ndarray): Processed bubble image (cv2 format - BGR).
13
+ text (str): Text to be placed inside the speech bubble.
14
+ font_path (str): Font path.
15
+ bubble_contour (numpy.ndarray): Contour of the detected speech bubble.
16
+
17
+ Returns:
18
+ numpy.ndarray: Image with text placed inside the speech bubble.
19
+ """
20
+ pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
21
+ draw = ImageDraw.Draw(pil_image)
22
+
23
+ x, y, w, h = cv2.boundingRect(bubble_contour)
24
+
25
+ line_height = 16
26
+ font_size = 14
27
+ wrapping_ratio = 0.075
28
+
29
+ wrapped_text = textwrap.fill(text, width=int(w * wrapping_ratio),
30
+ break_long_words=True)
31
+
32
+ font = ImageFont.truetype(font_path, size=font_size)
33
+
34
+ lines = wrapped_text.split('\n')
35
+ total_text_height = (len(lines)) * line_height
36
+
37
+ while total_text_height > h:
38
+ line_height -= 2
39
+ font_size -= 2
40
+ wrapping_ratio += 0.025
41
+
42
+ wrapped_text = textwrap.fill(text, width=int(w * wrapping_ratio),
43
+ break_long_words=True)
44
+
45
+ font = ImageFont.truetype(font_path, size=font_size)
46
+
47
+ lines = wrapped_text.split('\n')
48
+ total_text_height = (len(lines)) * line_height
49
+
50
+ # Vertical centering
51
+ text_y = y + (h - total_text_height) // 2
52
+
53
+ for line in lines:
54
+ text_length = draw.textlength(line, font=font)
55
+
56
+ # Horizontal centering
57
+ text_x = x + (w - text_length) // 2
58
+
59
+ draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0))
60
+
61
+ text_y += line_height
62
+
63
+ image[:, :, :] = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
64
+
65
+ return image
demo/app.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from add_text import add_text
2
+ from detect_bubbles import detect_bubbles
3
+ from process_bubble import process_bubble
4
+ from translator import MangaTranslator
5
+ from ultralytics import YOLO
6
+ from manga_ocr import MangaOcr
7
+ from PIL import Image
8
+ import gradio as gr
9
+ import numpy as np
10
+ import cv2
11
+
12
+
13
+ MODEL = "model.pt"
14
+ EXAMPLE_LIST = [["examples/0.png"],
15
+ ["examples/ex0.png"]]
16
+ TITLE = "Manga Translator"
17
+ DESCRIPTION = "Translate text in manga bubbles!"
18
+
19
+
20
+ def predict(img, translation_method, font):
21
+ if translation_method == None:
22
+ translation_method = "google"
23
+ if font == None:
24
+ font = "fonts/animeace_i.ttf"
25
+
26
+ results = detect_bubbles(MODEL, img)
27
+
28
+ manga_translator = MangaTranslator()
29
+ mocr = MangaOcr()
30
+
31
+ image = np.array(img)
32
+
33
+ for result in results:
34
+ x1, y1, x2, y2, score, class_id = result
35
+
36
+ detected_image = image[int(y1):int(y2), int(x1):int(x2)]
37
+
38
+ im = Image.fromarray(np.uint8((detected_image)*255))
39
+ text = mocr(im)
40
+
41
+ detected_image, cont = process_bubble(detected_image)
42
+
43
+ text_translated = manga_translator.translate(text,
44
+ method=translation_method)
45
+
46
+ image[int(y1):int(y2), int(x1):int(x2)] = add_text(detected_image, text_translated, font, cont)
47
+
48
+ return Image.fromarray(image)
49
+
50
+ demo = gr.Interface(fn=predict,
51
+ inputs=["image",
52
+ gr.Dropdown([("Google", "google"),
53
+ ("Helsinki-NLP's opus-mt-ja-en model",
54
+ "hf"),
55
+ ("Sogou", "sogou"),
56
+ ("Bing", "bing")],
57
+ label="Translation Method",
58
+ value="google"),
59
+ gr.Dropdown([("animeace_i", "fonts/animeace_i.ttf"),
60
+ ("mangati", "fonts/mangati.ttf"),
61
+ ("ariali", "fonts/ariali.ttf")],
62
+ label="Text Font",
63
+ value="fonts/animeace_i.ttf")
64
+ ],
65
+ outputs=[gr.Image()],
66
+ examples=EXAMPLE_LIST,
67
+ title=TITLE,
68
+ description=DESCRIPTION)
69
+
70
+
71
+ demo.launch(debug=False,
72
+ share=False)
demo/detect_bubbles.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.serialization
2
+ from ultralytics import YOLO
3
+
4
+ def detect_bubbles(model_path, image_path):
5
+ """
6
+ Detects bubbles in an image using a YOLOv8 model.
7
+ Args:
8
+ model_path (str): The file path to the YOLO model.
9
+ image_path (str): The file path to the input image.
10
+ Returns:
11
+ list: A list containing the coordinates, score, and class_id of
12
+ the detected bubbles.
13
+ """
14
+ with torch.serialization.safe_globals([YOLO]):
15
+ model = YOLO(model_path)
16
+
17
+ bubbles = model(image_path)[0]
18
+
19
+ return bubbles.boxes.data.tolist()
demo/examples/0.png ADDED

Git LFS Details

  • SHA256: cfccaf6c12b806994d153cc083dd595c4f43884a4de54504d1cfac82b4e79de2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.31 MB
demo/examples/ex0.png ADDED

Git LFS Details

  • SHA256: 5336c6e38a029c8037d745513bedf931486d3bdb74715d974e228f965e92a49f
  • Pointer size: 131 Bytes
  • Size of remote file: 974 kB
demo/fonts/animeace_i.ttf ADDED
Binary file (28.8 kB). View file
 
demo/fonts/ariali.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:79a5742b865cf06891d4ae43e42cac0e5169c7b7b06b5b3c24ef8e0966d27b62
3
+ size 717428
demo/fonts/mangati.ttf ADDED
Binary file (30.4 kB). View file
 
demo/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2f1a64e4e4c0dd30b361eb332866dea0f52eab9acb288b9ffdcb2622cb5d1cdb
3
+ size 6234585
demo/packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python3-opencv
demo/process_bubble.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+
4
+
5
+ def process_bubble(image):
6
+ """
7
+ Processes the speech bubble in the given image, making its contents white.
8
+
9
+ Parameters:
10
+ - image (numpy.ndarray): Input image.
11
+
12
+ Returns:
13
+ - image (numpy.ndarray): Image with the speech bubble content set to white.
14
+ - largest_contour (numpy.ndarray): Contour of the detected speech bubble.
15
+ """
16
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
17
+ _, thresh = cv2.threshold(gray, 240, 255, cv2.THRESH_BINARY)
18
+
19
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
20
+ largest_contour = max(contours, key=cv2.contourArea)
21
+
22
+ mask = np.zeros_like(gray)
23
+ cv2.drawContours(mask, [largest_contour], -1, 255, cv2.FILLED)
24
+
25
+ image[mask == 255] = (255, 255, 255)
26
+
27
+ return image, largest_contour
demo/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ deep-translator==1.11.4
2
+ translators==5.9.1
3
+ huggingface-hub==0.22.2
4
+ manga-ocr==0.1.11
5
+ numpy==1.24.2
6
+ opencv-python==4.9.0.80
7
+ pillow==10.3.0
8
+ ultralytics==8.1.43
9
+ sentencepiece==0.2.0
10
+ torch==2.5.0
demo/translator.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from deep_translator import GoogleTranslator
2
+ from transformers import pipeline
3
+ import translators as ts
4
+ import random
5
+ import time
6
+
7
+
8
+ class MangaTranslator:
9
+ def __init__(self):
10
+ self.target = "en"
11
+ self.source = "ja"
12
+ self.translators = {
13
+ "google": self._translate_with_google,
14
+ "hf": self._translate_with_hf,
15
+ "sogou": self._translate_with_sogou,
16
+ "bing": self._translate_with_bing
17
+ }
18
+
19
+ def translate(self, text, method="google"):
20
+ """
21
+ Translates the given text to the target language using the specified method.
22
+
23
+ Args:
24
+ text (str): The text to be translated.
25
+ method (str):"google" for Google Translator,
26
+ "hf" for Helsinki-NLP's opus-mt-ja-en model (HF pipeline)
27
+ "sogou" for Sogou Translate
28
+ "bing" for Microsoft Bing Translator
29
+
30
+ Returns:
31
+ str: The translated text.
32
+ """
33
+ translator_func = self.translators.get(method)
34
+
35
+ if translator_func:
36
+ return translator_func(self._preprocess_text(text))
37
+ else:
38
+ raise ValueError("Invalid translation method.")
39
+
40
+ def _translate_with_google(self, text):
41
+ self._delay()
42
+ translator = GoogleTranslator(source=self.source, target=self.target)
43
+ translated_text = translator.translate(text)
44
+ return translated_text if translated_text is not None else text
45
+
46
+ def _translate_with_hf(self, text):
47
+ pipe = pipeline("translation", model=f"Helsinki-NLP/opus-mt-ja-en")
48
+ translated_text = pipe(text)[0]["translation_text"]
49
+ return translated_text if translated_text is not None else text
50
+
51
+ def _translate_with_sogou(self, text):
52
+ self._delay()
53
+ translated_text = ts.translate_text(text, translator="sogou",
54
+ from_language=self.source,
55
+ to_language=self.target)
56
+ return translated_text if translated_text is not None else text
57
+
58
+ def _translate_with_bing(self, text):
59
+ self._delay()
60
+ translated_text = ts.translate_text(text, translator="bing",
61
+ from_language=self.source,
62
+ to_language=self.target)
63
+ return translated_text if translated_text is not None else text
64
+
65
+ def _preprocess_text(self, text):
66
+ preprocessed_text = text.replace(".", ".")
67
+ return preprocessed_text
68
+
69
+ def _delay(self):
70
+ time.sleep(random.randint(3, 5))
examples/0.png ADDED

Git LFS Details

  • SHA256: cfccaf6c12b806994d153cc083dd595c4f43884a4de54504d1cfac82b4e79de2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.31 MB
examples/1.png ADDED

Git LFS Details

  • SHA256: ab9b80456431019170b545de2acf0ad2c3c0b945093939ef2b50a9458b11eb97
  • Pointer size: 132 Bytes
  • Size of remote file: 1.3 MB
examples/2.png ADDED

Git LFS Details

  • SHA256: a32b641717568084cecd5bc5212643cd37b7ad0e62b333b04e4fafacb58b0643
  • Pointer size: 132 Bytes
  • Size of remote file: 1.19 MB
examples/3.png ADDED

Git LFS Details

  • SHA256: a516cd53189bde9a3276cfca98355525e1411b800ccfb8441655e15de1d7aa2c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.21 MB
examples/ex0.png ADDED

Git LFS Details

  • SHA256: 5336c6e38a029c8037d745513bedf931486d3bdb74715d974e228f965e92a49f
  • Pointer size: 131 Bytes
  • Size of remote file: 974 kB
examples/ex1.png ADDED

Git LFS Details

  • SHA256: d7714d5cedc1a0e59dfa431332a8eb2a94c6588cc2f916e86f535f128ca8d8c3
  • Pointer size: 131 Bytes
  • Size of remote file: 970 kB
examples/ex2.png ADDED

Git LFS Details

  • SHA256: 0a5d540c30d7bed380c882025f96f4fa78c703671098a7731bc415c23d1ae052
  • Pointer size: 131 Bytes
  • Size of remote file: 933 kB
examples/ex3.png ADDED

Git LFS Details

  • SHA256: 6e3dc0848787b97736ee3763546daaa8ad8b4023b266c3f6038e05b2c40f7c41
  • Pointer size: 131 Bytes
  • Size of remote file: 945 kB
fonts/animeace_i.ttf ADDED
Binary file (53.9 kB). View file
 
fonts/ariali.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:79a5742b865cf06891d4ae43e42cac0e5169c7b7b06b5b3c24ef8e0966d27b62
3
+ size 717428
fonts/mangati.ttf ADDED
Binary file (30.4 kB). View file
 
model/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2f1a64e4e4c0dd30b361eb332866dea0f52eab9acb288b9ffdcb2622cb5d1cdb
3
+ size 6234585
model/model_training.ipynb ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 0,
4
+ "metadata": {
5
+ "colab": {
6
+ "provenance": []
7
+ },
8
+ "kernelspec": {
9
+ "name": "python3",
10
+ "display_name": "Python 3"
11
+ },
12
+ "language_info": {
13
+ "name": "python"
14
+ }
15
+ },
16
+ "cells": [
17
+ {
18
+ "cell_type": "markdown",
19
+ "source": [
20
+ "**YOLOv8 model training using ultralytics**"
21
+ ],
22
+ "metadata": {
23
+ "id": "7PihFE53D92W"
24
+ }
25
+ },
26
+ {
27
+ "cell_type": "code",
28
+ "execution_count": null,
29
+ "metadata": {
30
+ "id": "toxMDptj5Psd"
31
+ },
32
+ "outputs": [],
33
+ "source": [
34
+ "!pip install ultralytics"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "code",
39
+ "source": [
40
+ "from ultralytics import YOLO\n",
41
+ "import os"
42
+ ],
43
+ "metadata": {
44
+ "id": "0Jw1Ow2A5_Sf"
45
+ },
46
+ "execution_count": null,
47
+ "outputs": []
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "source": [
52
+ "# Path to the root directory that contains config.yaml file and the dataset\n",
53
+ "# annotated in the YOLOv8 format\n",
54
+ "ROOT_DIR = \"Path\"\n",
55
+ "\n",
56
+ "# Path to the pretrained model if exists\n",
57
+ "PRETRAINED_MODEL_PATH = \"Path\"\n",
58
+ "\n",
59
+ "# Number of iterations (forward pass) to train the model\n",
60
+ "NUM_EPOCHS = 100"
61
+ ],
62
+ "metadata": {
63
+ "id": "NDclcuR05QZP"
64
+ },
65
+ "execution_count": null,
66
+ "outputs": []
67
+ },
68
+ {
69
+ "cell_type": "code",
70
+ "source": [
71
+ "def train_model(load_model=False):\n",
72
+ " if load_model:\n",
73
+ " model = YOLO(PRETRAINED_MODEL_PATH)\n",
74
+ " results = model.train(data=os.path.join(ROOT_DIR, \"config.yaml\"),\n",
75
+ " epochs=NUM_EPOCHS)\n",
76
+ " else:\n",
77
+ " model = YOLO(\"yolov8n.yaml\")\n",
78
+ " results = model.train(data=os.path.join(ROOT_DIR, \"config.yaml\"),\n",
79
+ " epochs=NUM_EPOCHS)\n",
80
+ ""
81
+ ],
82
+ "metadata": {
83
+ "id": "PAYhaotC5QcB"
84
+ },
85
+ "execution_count": null,
86
+ "outputs": []
87
+ },
88
+ {
89
+ "cell_type": "code",
90
+ "source": [
91
+ "train_model()"
92
+ ],
93
+ "metadata": {
94
+ "id": "mCxI3Xlr7Ryu"
95
+ },
96
+ "execution_count": null,
97
+ "outputs": []
98
+ }
99
+ ]
100
+ }
ocr/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # OCR modules
2
+ from .chrome_lens_ocr import ChromeLensOCR
3
+
4
+ __all__ = ["ChromeLensOCR"]
ocr/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (257 Bytes). View file
 
ocr/__pycache__/chrome_lens_ocr.cpython-311.pyc ADDED
Binary file (5.46 kB). View file
 
ocr/chrome_lens_ocr.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chrome Lens OCR module using chrome-lens-py library.
3
+ Provides OCR functionality using Google Lens API.
4
+ """
5
+ import asyncio
6
+ from PIL import Image
7
+ import numpy as np
8
+
9
+ from chrome_lens_py import LensAPI
10
+
11
+
12
+ class ChromeLensOCR:
13
+ """
14
+ OCR engine using Google Chrome Lens API via chrome-lens-py.
15
+
16
+ This provides an alternative to manga-ocr with the following benefits:
17
+ - Free Google Lens OCR API
18
+ - Multi-language support with auto-detection
19
+ - Text block segmentation for comics/manga
20
+ """
21
+
22
+ def __init__(self, ocr_language: str = "ja"):
23
+ """
24
+ Initialize Chrome Lens OCR.
25
+
26
+ Args:
27
+ ocr_language: BCP 47 language code for OCR (default: "ja" for Japanese)
28
+ """
29
+ self.api = LensAPI()
30
+ self.ocr_language = ocr_language
31
+
32
+ def __call__(self, image) -> str:
33
+ """
34
+ Process an image and extract text.
35
+
36
+ Args:
37
+ image: Can be a PIL Image, numpy array, file path, or URL
38
+
39
+ Returns:
40
+ str: Extracted text from the image
41
+ """
42
+ # Handle different image input types
43
+ if isinstance(image, np.ndarray):
44
+ # Convert numpy array to PIL Image
45
+ image = Image.fromarray(image)
46
+
47
+ # Use cached event loop to avoid overhead
48
+ try:
49
+ loop = asyncio.get_running_loop()
50
+ # If there's a running loop, use run_coroutine_threadsafe
51
+ import concurrent.futures
52
+ future = asyncio.run_coroutine_threadsafe(self._process(image), loop)
53
+ return future.result(timeout=30)
54
+ except RuntimeError:
55
+ # No running loop, create one (but try to reuse)
56
+ if not hasattr(self, '_loop') or self._loop.is_closed():
57
+ self._loop = asyncio.new_event_loop()
58
+ return self._loop.run_until_complete(self._process(image))
59
+
60
+ async def _process(self, image) -> str:
61
+ """
62
+ Async method to process image with Chrome Lens API.
63
+
64
+ Args:
65
+ image: PIL Image, file path, or URL
66
+
67
+ Returns:
68
+ str: Extracted text
69
+ """
70
+ try:
71
+ result = await self.api.process_image(
72
+ image_path=image,
73
+ ocr_language=self.ocr_language
74
+ )
75
+ return result.get("ocr_text", "")
76
+ except Exception as e:
77
+ print(f"Chrome Lens OCR error: {e}")
78
+ return ""
79
+
80
+ async def process_with_blocks(self, image) -> dict:
81
+ """
82
+ Process image and return text segmented into blocks.
83
+ Useful for manga/comics with multiple speech bubbles.
84
+
85
+ Args:
86
+ image: PIL Image, file path, or URL
87
+
88
+ Returns:
89
+ dict: Contains 'text_blocks' with segmented text and geometry
90
+ """
91
+ try:
92
+ result = await self.api.process_image(
93
+ image_path=image,
94
+ ocr_language=self.ocr_language,
95
+ output_format='blocks'
96
+ )
97
+ return result
98
+ except Exception as e:
99
+ print(f"Chrome Lens OCR error: {e}")
100
+ return {"text_blocks": []}
101
+
102
+ def get_text_blocks(self, image) -> list:
103
+ """
104
+ Synchronous wrapper to get text blocks from image.
105
+
106
+ Args:
107
+ image: PIL Image, numpy array, file path, or URL
108
+
109
+ Returns:
110
+ list: List of text blocks with text and geometry
111
+ """
112
+ if isinstance(image, np.ndarray):
113
+ image = Image.fromarray(image)
114
+
115
+ result = asyncio.run(self.process_with_blocks(image))
116
+ return result.get("text_blocks", [])
static/css/style.css ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: 'Exo 2', sans-serif;
3
+ background-color: #f7f7f7;
4
+ margin: 0;
5
+ min-height: 100vh;
6
+ display: flex;
7
+ flex-direction: column;
8
+ align-items: center;
9
+ }
10
+
11
+ header {
12
+ width: 100%;
13
+ box-shadow: 0 0 10px #cccccc;
14
+ background-image: url("../img/header.png");
15
+ background-size: 100% 100%;
16
+ padding: 1.4%;
17
+ margin-bottom: 40px;
18
+ }
19
+
20
+ .container {
21
+ background-color: #fff;
22
+ border-radius: 16px;
23
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
24
+ padding: 30px 40px;
25
+ width: 520px;
26
+ max-width: 90%;
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ .container>img {
31
+ display: block;
32
+ margin: 0 auto 20px;
33
+ width: 180px;
34
+ height: auto;
35
+ }
36
+
37
+ form {
38
+ text-align: center;
39
+ }
40
+
41
+ /* 2-column grid layout */
42
+ .form-grid {
43
+ display: grid;
44
+ grid-template-columns: 1fr 1fr;
45
+ gap: 15px 20px;
46
+ margin-bottom: 20px;
47
+ }
48
+
49
+ .select-wrapper {
50
+ text-align: left;
51
+ }
52
+
53
+ .select-wrapper.full-width {
54
+ grid-column: 1 / -1;
55
+ }
56
+
57
+ label.translator-label {
58
+ display: block;
59
+ font-size: 13px;
60
+ font-weight: 600;
61
+ color: #333;
62
+ margin-bottom: 6px;
63
+ }
64
+
65
+ .custom-select {
66
+ position: relative;
67
+ width: 100%;
68
+ }
69
+
70
+ .select-box {
71
+ border: 1px solid #ddd;
72
+ border-radius: 8px;
73
+ padding: 10px 14px;
74
+ cursor: pointer;
75
+ user-select: none;
76
+ background-color: #fafafa;
77
+ display: flex;
78
+ justify-content: space-between;
79
+ align-items: center;
80
+ transition: border-color 0.2s, box-shadow 0.2s;
81
+ }
82
+
83
+ .select-box:hover {
84
+ border-color: #5E1675;
85
+ }
86
+
87
+ .selected {
88
+ font-size: 14px;
89
+ overflow: hidden;
90
+ white-space: nowrap;
91
+ text-overflow: ellipsis;
92
+ }
93
+
94
+ .icon {
95
+ color: #999;
96
+ font-size: 10px;
97
+ transition: transform 0.2s;
98
+ }
99
+
100
+ .custom-select.open .icon {
101
+ transform: rotate(180deg);
102
+ }
103
+
104
+ .options {
105
+ position: absolute;
106
+ top: calc(100% + 4px);
107
+ left: 0;
108
+ right: 0;
109
+ z-index: 100;
110
+ display: none;
111
+ border: 1px solid #ddd;
112
+ border-radius: 8px;
113
+ background-color: white;
114
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
115
+ max-height: 200px;
116
+ overflow-y: auto;
117
+ }
118
+
119
+ .option {
120
+ display: block;
121
+ padding: 10px 14px;
122
+ font-size: 14px;
123
+ cursor: pointer;
124
+ transition: background-color 0.15s;
125
+ }
126
+
127
+ .option:hover {
128
+ background-color: #f5f0f7;
129
+ }
130
+
131
+ .option.selected {
132
+ background-color: #f0e6f5;
133
+ font-weight: 600;
134
+ color: #5E1675;
135
+ }
136
+
137
+ /* Custom prompt textarea */
138
+ #custom_prompt {
139
+ width: 100%;
140
+ border-radius: 8px;
141
+ padding: 10px 14px;
142
+ border: 1px solid #ddd;
143
+ font-family: inherit;
144
+ font-size: 14px;
145
+ resize: vertical;
146
+ box-sizing: border-box;
147
+ }
148
+
149
+ #custom_prompt:focus {
150
+ outline: none;
151
+ border-color: #5E1675;
152
+ }
153
+
154
+ /* File upload */
155
+ input[type="file"] {
156
+ display: none;
157
+ }
158
+
159
+ .file {
160
+ display: block;
161
+ width: 100%;
162
+ padding: 14px;
163
+ border: 2px dashed #5E1675;
164
+ border-radius: 10px;
165
+ text-align: center;
166
+ cursor: pointer;
167
+ color: #5E1675;
168
+ font-weight: 500;
169
+ transition: background-color 0.2s, border-color 0.2s;
170
+ margin-bottom: 10px;
171
+ }
172
+
173
+ .file:hover {
174
+ background-color: #f9f5fb;
175
+ }
176
+
177
+ .file-list {
178
+ margin-bottom: 15px;
179
+ }
180
+
181
+ .file-item {
182
+ font-size: 12px;
183
+ color: #666;
184
+ padding: 4px 10px;
185
+ background: #f5f5f5;
186
+ border-radius: 4px;
187
+ margin: 4px 0;
188
+ text-align: left;
189
+ }
190
+
191
+ .file-item.more {
192
+ color: #5E1675;
193
+ font-style: italic;
194
+ }
195
+
196
+ /* Submit button */
197
+ button {
198
+ width: 100%;
199
+ padding: 14px;
200
+ background-color: #5E1675;
201
+ color: white;
202
+ border: none;
203
+ border-radius: 10px;
204
+ cursor: pointer;
205
+ font-size: 16px;
206
+ font-weight: 600;
207
+ transition: background-color 0.2s, transform 0.1s;
208
+ }
209
+
210
+ button:hover {
211
+ background-color: #4a1160;
212
+ }
213
+
214
+ button:active {
215
+ transform: scale(0.98);
216
+ }
217
+
218
+ /* Loading */
219
+ #loading-img {
220
+ display: none;
221
+ width: 60px;
222
+ margin: 20px auto;
223
+ }
224
+
225
+ #loading-p {
226
+ display: none;
227
+ text-align: center;
228
+ color: #666;
229
+ }
230
+
231
+ /* Results page */
232
+ .results-container {
233
+ max-width: 1200px;
234
+ margin: 40px auto;
235
+ padding: 20px;
236
+ }
237
+
238
+ .results-title {
239
+ text-align: center;
240
+ color: #5E1675;
241
+ margin-bottom: 30px;
242
+ font-size: 24px;
243
+ }
244
+
245
+ .image-gallery {
246
+ display: grid;
247
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
248
+ gap: 20px;
249
+ }
250
+
251
+ .image-card {
252
+ background: white;
253
+ border-radius: 12px;
254
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
255
+ overflow: hidden;
256
+ transition: transform 0.3s, box-shadow 0.3s;
257
+ }
258
+
259
+ .image-card:hover {
260
+ transform: translateY(-5px);
261
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
262
+ }
263
+
264
+ .gallery-image {
265
+ width: 100%;
266
+ height: auto;
267
+ max-height: 400px;
268
+ object-fit: contain;
269
+ background: #f8f8f8;
270
+ }
271
+
272
+ .image-info {
273
+ padding: 15px;
274
+ display: flex;
275
+ justify-content: space-between;
276
+ align-items: center;
277
+ }
278
+
279
+ .image-name {
280
+ font-weight: 500;
281
+ color: #333;
282
+ overflow: hidden;
283
+ text-overflow: ellipsis;
284
+ white-space: nowrap;
285
+ max-width: 60%;
286
+ }
287
+
288
+ .download-btn {
289
+ background: #50C878;
290
+ color: white;
291
+ padding: 8px 15px;
292
+ border-radius: 6px;
293
+ text-decoration: none;
294
+ font-size: 14px;
295
+ transition: background 0.2s;
296
+ }
297
+
298
+ .download-btn:hover {
299
+ background: #3da85c;
300
+ }
301
+
302
+ .buttons_image {
303
+ display: flex;
304
+ justify-content: center;
305
+ gap: 15px;
306
+ margin-top: 20px;
307
+ }
308
+
309
+ .buttons_image a {
310
+ padding: 12px 24px;
311
+ border-radius: 8px;
312
+ text-decoration: none;
313
+ font-weight: 600;
314
+ transition: all 0.2s;
315
+ }
316
+
317
+ .green {
318
+ background: #50C878;
319
+ color: white;
320
+ }
321
+
322
+ .green:hover {
323
+ background: #3da85c;
324
+ }
325
+
326
+ .red {
327
+ background: white;
328
+ color: #fd5c63;
329
+ border: 2px solid #fd5c63;
330
+ }
331
+
332
+ .red:hover {
333
+ background: #fd5c63;
334
+ color: white;
335
+ }
336
+
337
+ /* Responsive */
338
+ @media (max-width: 600px) {
339
+ .form-grid {
340
+ grid-template-columns: 1fr;
341
+ }
342
+
343
+ .container {
344
+ padding: 20px;
345
+ }
346
+ }
static/img/header.png ADDED
static/img/loading.gif ADDED

Git LFS Details

  • SHA256: b5e6c0bfb72db987730d9716676b1078d26ead7c985e717a8ca7377380de2b25
  • Pointer size: 132 Bytes
  • Size of remote file: 1.24 MB
static/img/logo.png ADDED
static/js/app.js ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Sets up custom dropdown behavior for all select wrappers
2
+ document.addEventListener("DOMContentLoaded", () => {
3
+ const selectWrappers = document.querySelectorAll('.select-wrapper');
4
+
5
+ selectWrappers.forEach(wrapper => {
6
+ const selectBox = wrapper.querySelector('.custom-select');
7
+ if (!selectBox) return;
8
+
9
+ const selectedText = selectBox.querySelector('.selected');
10
+ const options = selectBox.querySelector('.options');
11
+ const optionList = selectBox.querySelectorAll('.option');
12
+
13
+ if (!optionList.length) return;
14
+
15
+ const defaultOption = optionList[0];
16
+ selectedText.textContent = defaultOption.textContent;
17
+ defaultOption.classList.add('selected');
18
+
19
+ // Toggle options display on select box click
20
+ selectBox.addEventListener('click', () => {
21
+ options.style.display = options.style.display === 'block' ? 'none' : 'block';
22
+ selectBox.classList.toggle('open');
23
+ });
24
+
25
+ // Update selected option and hide options on option click
26
+ optionList.forEach(option => {
27
+ option.addEventListener('click', () => {
28
+ selectedText.textContent = option.textContent;
29
+ optionList.forEach(opt => opt.classList.remove('selected'));
30
+ option.classList.add('selected');
31
+
32
+ // Show/hide custom prompt textarea
33
+ if (selectBox.id === 'style') {
34
+ const customWrapper = document.getElementById('custom-prompt-wrapper');
35
+ if (option.textContent.includes('Custom')) {
36
+ customWrapper.style.display = 'block';
37
+ } else {
38
+ customWrapper.style.display = 'none';
39
+ }
40
+ }
41
+ });
42
+ });
43
+
44
+ // Hide options when clicking outside the select box
45
+ window.addEventListener('click', e => {
46
+ if (!wrapper.contains(e.target)) {
47
+ options.style.display = 'none';
48
+ selectBox.classList.remove('open');
49
+ }
50
+ });
51
+ });
52
+ });
53
+
54
+ // Handles multiple file upload change event
55
+ const fileUpload = document.getElementById('file-upload');
56
+ if (fileUpload) {
57
+ fileUpload.addEventListener('change', function () {
58
+ const files = this.files;
59
+ const fileList = document.getElementById('file-list');
60
+ const fileText = document.getElementById('file-text');
61
+
62
+ if (files.length === 0) {
63
+ fileText.textContent = '📁 Chọn ảnh (có thể chọn nhiều)';
64
+ fileList.innerHTML = '';
65
+ return;
66
+ }
67
+
68
+ if (files.length === 1) {
69
+ fileText.textContent = truncateFileName(files[0].name, 25);
70
+ fileList.innerHTML = '';
71
+ } else {
72
+ fileText.textContent = `📁 ${files.length} ảnh đã chọn`;
73
+
74
+ // Show file list preview
75
+ fileList.innerHTML = '';
76
+ for (let i = 0; i < Math.min(files.length, 5); i++) {
77
+ const fileItem = document.createElement('div');
78
+ fileItem.className = 'file-item';
79
+ fileItem.textContent = truncateFileName(files[i].name, 30);
80
+ fileList.appendChild(fileItem);
81
+ }
82
+
83
+ if (files.length > 5) {
84
+ const moreItem = document.createElement('div');
85
+ moreItem.className = 'file-item more';
86
+ moreItem.textContent = `... và ${files.length - 5} ảnh khác`;
87
+ fileList.appendChild(moreItem);
88
+ }
89
+ }
90
+ });
91
+ }
92
+
93
+ // Truncates file name if it exceeds the maximum length
94
+ function truncateFileName(fileName, maxLength) {
95
+ return fileName.length <= maxLength ? fileName : fileName.substr(0, maxLength - 3) + '...';
96
+ }
97
+
98
+ // Updates hidden input fields with selected options
99
+ function updateHiddenInputs() {
100
+ const getSelectedText = (id) => {
101
+ const el = document.querySelector(`#${id} .selected`);
102
+ return el ? el.innerText : '';
103
+ };
104
+
105
+ document.getElementById("selected_source_lang").value = getSelectedText("source_lang");
106
+ document.getElementById("selected_language").value = getSelectedText("language");
107
+ document.getElementById("selected_translator").value = getSelectedText("translator");
108
+ document.getElementById("selected_style").value = getSelectedText("style");
109
+ document.getElementById("selected_font").value = getSelectedText("font");
110
+ document.getElementById("selected_ocr").value = getSelectedText("ocr");
111
+
112
+ // Check if files are selected
113
+ const files = document.getElementById('file-upload').files;
114
+ if (files.length === 0) {
115
+ alert('Vui lòng chọn ít nhất 1 ảnh!');
116
+ return false;
117
+ }
118
+
119
+ document.querySelector('form').style.display = 'none';
120
+ document.getElementById('loading-img').style.display = 'block';
121
+ document.getElementById('loading-p').style.display = 'block';
122
+
123
+ return true;
124
+ }
templates/index.html ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <title>Manga Translator</title>
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
11
+ <link href="https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap"
12
+ rel="stylesheet">
13
+ </head>
14
+
15
+ <body>
16
+ <header>
17
+ </header>
18
+ <div class="container">
19
+ <img src="{{ url_for('static', filename='img/logo.png') }}" alt="Manga Translator">
20
+ <form action="/translate" method="post" enctype="multipart/form-data" onsubmit="return updateHiddenInputs()">
21
+
22
+ <!-- 2-column grid for dropdowns -->
23
+ <div class="form-grid">
24
+ <!-- Row 1: Source & Target Language -->
25
+ <div class="select-wrapper">
26
+ <label class="translator-label">Ngôn ngữ gốc</label>
27
+ <div class="custom-select" id="source_lang" tabindex="0">
28
+ <div class="select-box">
29
+ <span class="selected"></span>
30
+ <span class="icon">&#9660;</span>
31
+ </div>
32
+ <div class="options">
33
+ <span class="option">Japanese (Manga)</span>
34
+ <span class="option">Chinese (Manhua)</span>
35
+ <span class="option">Korean (Manhwa)</span>
36
+ <span class="option">English (Comic)</span>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="select-wrapper">
42
+ <label class="translator-label">Dịch sang</label>
43
+ <div class="custom-select" id="language" tabindex="0">
44
+ <div class="select-box">
45
+ <span class="selected"></span>
46
+ <span class="icon">&#9660;</span>
47
+ </div>
48
+ <div class="options">
49
+ <span class="option">Vietnamese</span>
50
+ <span class="option">English</span>
51
+ <span class="option">Chinese</span>
52
+ <span class="option">Korean</span>
53
+ <span class="option">Thai</span>
54
+ <span class="option">Indonesian</span>
55
+ <span class="option">French</span>
56
+ <span class="option">German</span>
57
+ <span class="option">Spanish</span>
58
+ <span class="option">Russian</span>
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Row 2: Translator & Style -->
64
+ <div class="select-wrapper">
65
+ <label class="translator-label">Translator</label>
66
+ <div class="custom-select" id="translator" tabindex="0">
67
+ <div class="select-box">
68
+ <span class="selected"></span>
69
+ <span class="icon">&#9660;</span>
70
+ </div>
71
+ <div class="options">
72
+ <span class="option">Gemini</span>
73
+ <span class="option">Google</span>
74
+ <span class="option">NLLB</span>
75
+ <span class="option">Baidu</span>
76
+ <span class="option">Bing</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="select-wrapper">
82
+ <label class="translator-label">Phong cách dịch</label>
83
+ <div class="custom-select" id="style" tabindex="0">
84
+ <div class="select-box">
85
+ <span class="selected"></span>
86
+ <span class="icon">&#9660;</span>
87
+ </div>
88
+ <div class="options">
89
+ <span class="option">Default</span>
90
+ <span class="option">Casual (thân mật)</span>
91
+ <span class="option">Formal (trang trọng)</span>
92
+ <span class="option">Keep Honorifics (-san, senpai...)</span>
93
+ <span class="option">Web Novel Style</span>
94
+ <span class="option">Action (ngắn gọn)</span>
95
+ <span class="option">Literal (sát nghĩa)</span>
96
+ <span class="option">Custom...</span>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Row 3: Font & OCR -->
102
+ <div class="select-wrapper">
103
+ <label class="translator-label">Font</label>
104
+ <div class="custom-select" id="font" tabindex="0">
105
+ <div class="select-box">
106
+ <span class="selected"></span>
107
+ <span class="icon">&#9660;</span>
108
+ </div>
109
+ <div class="options">
110
+ <span class="option">Animeace</span>
111
+ <span class="option">Mangat</span>
112
+ <span class="option">Arial</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <div class="select-wrapper">
118
+ <label class="translator-label">OCR Engine</label>
119
+ <div class="custom-select" id="ocr" tabindex="0">
120
+ <div class="select-box">
121
+ <span class="selected"></span>
122
+ <span class="icon">&#9660;</span>
123
+ </div>
124
+ <div class="options">
125
+ <span class="option">Chrome-Lens</span>
126
+ <span class="option">Manga-OCR</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Custom Prompt (show when Custom selected) -->
133
+ <div class="select-wrapper full-width" id="custom-prompt-wrapper" style="display: none;">
134
+ <label class="translator-label">Custom Prompt</label>
135
+ <textarea id="custom_prompt" name="custom_prompt"
136
+ placeholder="Ví dụ: Dịch theo phong cách light novel, giữ nguyên tên nhân vật..." rows="2"></textarea>
137
+ </div>
138
+
139
+ <!-- File upload -->
140
+ <input id="file-upload" type="file" name="files" accept=".jpg, .jpeg, .png" multiple required>
141
+ <label for="file-upload" class="file" id="file-label">
142
+ <span id="file-text">📁 Chọn ảnh</span>
143
+ </label>
144
+ <div id="file-list" class="file-list"></div>
145
+
146
+ <input type="hidden" id="selected_source_lang" name="selected_source_lang">
147
+ <input type="hidden" id="selected_language" name="selected_language">
148
+ <input type="hidden" id="selected_translator" name="selected_translator">
149
+ <input type="hidden" id="selected_style" name="selected_style">
150
+ <input type="hidden" id="selected_font" name="selected_font">
151
+ <input type="hidden" id="selected_ocr" name="selected_ocr">
152
+ <button type="submit">Translate</button>
153
+ </form>
154
+ <img id="loading-img" src="{{ url_for('static', filename='img/loading.gif') }}" alt="">
155
+ <p id="loading-p">Đang xử lý... Vui lòng đợi!</p>
156
+ </div>
157
+
158
+ <script src="{{ url_for('static', filename='js/app.js') }}"></script>
159
+ </body>
160
+
161
+ </html>
templates/translate.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Manga Translator - Results</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
+ <link href="https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap"
9
+ rel="stylesheet">
10
+ </head>
11
+
12
+ <body>
13
+ <header>
14
+ </header>
15
+
16
+ <div class="results-container">
17
+ <h2 class="results-title">✨ Kết quả dịch ({{ images|length }} ảnh)</h2>
18
+
19
+ {% if images %}
20
+ <div class="image-gallery">
21
+ {% for img in images %}
22
+ <div class="image-card">
23
+ <img class="gallery-image" src="data:image/jpeg;base64,{{ img.data }}" alt="{{ img.name }}">
24
+ <div class="image-info">
25
+ <span class="image-name">{{ img.name }}</span>
26
+ <a href="#" class="download-btn" data-image="{{ img.data }}" data-name="{{ img.name }}">
27
+ 💾 Download
28
+ </a>
29
+ </div>
30
+ </div>
31
+ {% endfor %}
32
+ </div>
33
+ {% else %}
34
+ <p class="no-images">Không có ảnh nào được xử lý.</p>
35
+ {% endif %}
36
+ </div>
37
+
38
+ <div class="buttons_image">
39
+ <a href="#" class="green" id="download-all">📦 Download All</a>
40
+ <a href="/" class="red">← Quay lại</a>
41
+ </div>
42
+
43
+ </body>
44
+ <script>
45
+ // Download single image
46
+ document.querySelectorAll('.download-btn').forEach(btn => {
47
+ btn.addEventListener('click', (e) => {
48
+ e.preventDefault();
49
+ const imageData = btn.getAttribute('data-image');
50
+ const imageName = btn.getAttribute('data-name');
51
+ const a = document.createElement('a');
52
+ a.href = 'data:image/png;base64,' + imageData;
53
+ a.download = imageName + '_translated.png';
54
+ a.click();
55
+ });
56
+ });
57
+
58
+ // Download all images
59
+ document.getElementById('download-all').addEventListener('click', (e) => {
60
+ e.preventDefault();
61
+ document.querySelectorAll('.download-btn').forEach((btn, index) => {
62
+ setTimeout(() => {
63
+ btn.click();
64
+ }, index * 300); // Delay between downloads
65
+ });
66
+ });
67
+ </script>
68
+
69
+ </html>
translator/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Translator modules
2
+ from .translator import MangaTranslator
3
+ from .gemini_translator import GeminiTranslator
4
+
5
+ __all__ = ["MangaTranslator", "GeminiTranslator"]
translator/__pycache__/gemini_translator.cpython-311.pyc ADDED
Binary file (13.8 kB). View file
 
translator/__pycache__/translator.cpython-311.pyc ADDED
Binary file (8.66 kB). View file
 
translator/gemini_translator.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gemini Translator with Batch Processing
3
+ Uses Gemini 2.5 Flash-Lite for cost-effective translation
4
+ Supports multiple source languages and custom prompts
5
+ """
6
+ import google.generativeai as genai
7
+ import json
8
+ import os
9
+ from typing import List, Dict, Optional
10
+
11
+
12
+ class GeminiTranslator:
13
+ """
14
+ Translator using Google Gemini 2.5 Flash-Lite.
15
+ Supports batch translation to minimize API calls.
16
+ """
17
+
18
+ LANG_NAMES = {
19
+ "ja": "Japanese",
20
+ "zh": "Chinese",
21
+ "ko": "Korean",
22
+ "en": "English",
23
+ "vi": "Vietnamese",
24
+ "th": "Thai",
25
+ "id": "Indonesian",
26
+ "fr": "French",
27
+ "de": "German",
28
+ "es": "Spanish",
29
+ "ru": "Russian"
30
+ }
31
+
32
+ # Preset style templates
33
+ STYLE_PRESETS = {
34
+ "default": "",
35
+ "formal": "Use formal language and polite expressions.",
36
+ "casual": "Use casual, friendly language like talking to friends.",
37
+ "keep_honorifics": "Keep Japanese honorifics like -san, -kun, -chan, -sama, senpai, sensei.",
38
+ "localize": "Fully localize the text, replace cultural references with equivalent ones in target language.",
39
+ "literal": "Translate as literally as possible while maintaining readability.",
40
+ "web_novel": "Use web novel translation style with dramatic expressions.",
41
+ "action": "Use punchy, short sentences suitable for action scenes.",
42
+ }
43
+
44
+ def __init__(self, api_key: str = None, custom_prompt: str = None, style: str = "default"):
45
+ """
46
+ Initialize Gemini translator.
47
+
48
+ Args:
49
+ api_key: Gemini API key. If None, reads from GEMINI_API_KEY env var.
50
+ custom_prompt: Custom instructions for translation style.
51
+ style: Preset style name from STYLE_PRESETS.
52
+ """
53
+ self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
54
+ if not self.api_key:
55
+ raise ValueError("Gemini API key required. Set GEMINI_API_KEY or pass api_key.")
56
+
57
+ genai.configure(api_key=self.api_key)
58
+ self.model = genai.GenerativeModel("gemini-2.5-flash-lite")
59
+
60
+ # Set custom prompt (user prompt takes priority over preset)
61
+ self.custom_prompt = custom_prompt or self.STYLE_PRESETS.get(style, "")
62
+
63
+ def set_custom_prompt(self, prompt: str):
64
+ """Update custom prompt for translation style."""
65
+ self.custom_prompt = prompt
66
+
67
+ def _build_style_instructions(self) -> str:
68
+ """Build style instructions for the prompt."""
69
+ if self.custom_prompt:
70
+ return f"\n\nStyle instructions: {self.custom_prompt}"
71
+ return ""
72
+
73
+ def translate_single(
74
+ self,
75
+ text: str,
76
+ source: str = "ja",
77
+ target: str = "en",
78
+ custom_prompt: str = None
79
+ ) -> str:
80
+ """
81
+ Translate a single text string.
82
+
83
+ Args:
84
+ text: Text to translate
85
+ source: Source language code (ja, zh, ko, etc.)
86
+ target: Target language code
87
+ custom_prompt: Override custom prompt for this call
88
+
89
+ Returns:
90
+ Translated text
91
+ """
92
+ if not text or not text.strip():
93
+ return text
94
+
95
+ source_name = self.LANG_NAMES.get(source, "Japanese")
96
+ target_name = self.LANG_NAMES.get(target, "English")
97
+ style = custom_prompt or self.custom_prompt
98
+ style_text = f"\nStyle: {style}" if style else ""
99
+
100
+ prompt = f"""Translate the following {source_name} comic/manga text to {target_name}.
101
+ Keep the translation natural and suitable for comic dialogue.{style_text}
102
+ Only return the translated text, nothing else.
103
+
104
+ Text: {text}"""
105
+
106
+ try:
107
+ response = self.model.generate_content(prompt)
108
+ return response.text.strip()
109
+ except Exception as e:
110
+ print(f"Gemini translation error: {e}")
111
+ return text
112
+
113
+ def translate_batch(
114
+ self,
115
+ texts: List[str],
116
+ source: str = "ja",
117
+ target: str = "en",
118
+ custom_prompt: str = None
119
+ ) -> List[str]:
120
+ """
121
+ Translate multiple texts in a single API call.
122
+
123
+ Args:
124
+ texts: List of texts to translate
125
+ source: Source language code
126
+ target: Target language code
127
+ custom_prompt: Override custom prompt for this call
128
+
129
+ Returns:
130
+ List of translated texts (same order)
131
+ """
132
+ if not texts:
133
+ return []
134
+
135
+ # Filter empty texts but keep track of indices
136
+ indexed_texts = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
137
+
138
+ if not indexed_texts:
139
+ return texts
140
+
141
+ source_name = self.LANG_NAMES.get(source, "Japanese")
142
+ target_name = self.LANG_NAMES.get(target, "English")
143
+ texts_to_translate = [t for _, t in indexed_texts]
144
+
145
+ style = custom_prompt or self.custom_prompt
146
+ style_text = f"\nStyle instructions: {style}" if style else ""
147
+
148
+ prompt = f"""You are a professional comic/manga translator. Translate the following {source_name} texts to {target_name}.
149
+ Keep translations natural and suitable for comic speech bubbles.{style_text}
150
+
151
+ Input texts (JSON array):
152
+ {json.dumps(texts_to_translate, ensure_ascii=False)}
153
+
154
+ IMPORTANT: Return ONLY a JSON array with translated texts in the same order. No explanations.
155
+ Example output format: ["translated text 1", "translated text 2", ...]"""
156
+
157
+ try:
158
+ response = self.model.generate_content(prompt)
159
+ result_text = response.text.strip()
160
+
161
+ # Clean up response if needed
162
+ if result_text.startswith("```json"):
163
+ result_text = result_text[7:]
164
+ if result_text.startswith("```"):
165
+ result_text = result_text[3:]
166
+ if result_text.endswith("```"):
167
+ result_text = result_text[:-3]
168
+ result_text = result_text.strip()
169
+
170
+ translations = json.loads(result_text)
171
+
172
+ # Rebuild full list with original empty strings preserved
173
+ result = list(texts)
174
+ for (orig_idx, _), trans in zip(indexed_texts, translations):
175
+ result[orig_idx] = trans
176
+
177
+ return result
178
+
179
+ except Exception as e:
180
+ print(f"Gemini batch translation error: {e}")
181
+ # Fallback to single translations
182
+ return [self.translate_single(t, source, target) for t in texts]
183
+
184
+ def translate_pages_batch(
185
+ self,
186
+ pages_texts: Dict[str, List[str]],
187
+ source: str = "ja",
188
+ target: str = "en",
189
+ custom_prompt: str = None
190
+ ) -> Dict[str, List[str]]:
191
+ """
192
+ Translate texts from multiple pages in a single API call.
193
+ Ideal for batch processing 10 manga pages at once.
194
+
195
+ Args:
196
+ pages_texts: Dict mapping page names to list of texts
197
+ source: Source language code
198
+ target: Target language code
199
+ custom_prompt: Override custom prompt for this call
200
+
201
+ Returns:
202
+ Dict with same structure but translated texts
203
+ """
204
+ if not pages_texts:
205
+ return {}
206
+
207
+ source_name = self.LANG_NAMES.get(source, "Japanese")
208
+ target_name = self.LANG_NAMES.get(target, "English")
209
+
210
+ style = custom_prompt or self.custom_prompt
211
+ style_text = f"\nStyle instructions: {style}" if style else ""
212
+
213
+ prompt = f"""You are a professional comic/manga translator. Translate all {source_name} texts to {target_name}.
214
+ Keep translations natural, conversational, and suitable for comic speech bubbles.
215
+ Maintain the context and flow between pages as they are sequential comic pages.{style_text}
216
+
217
+ Input (JSON - page names with their text bubbles):
218
+ {json.dumps(pages_texts, ensure_ascii=False, indent=2)}
219
+
220
+ IMPORTANT: Return ONLY a JSON object with the exact same structure but with translated texts.
221
+ Keep the same page names and order. No explanations or markdown."""
222
+
223
+ try:
224
+ response = self.model.generate_content(prompt)
225
+ result_text = response.text.strip()
226
+
227
+ # Clean up response
228
+ if result_text.startswith("```json"):
229
+ result_text = result_text[7:]
230
+ if result_text.startswith("```"):
231
+ result_text = result_text[3:]
232
+ if result_text.endswith("```"):
233
+ result_text = result_text[:-3]
234
+ result_text = result_text.strip()
235
+
236
+ return json.loads(result_text)
237
+
238
+ except Exception as e:
239
+ print(f"Gemini pages batch translation error: {e}")
240
+ # Fallback: translate each page separately
241
+ result = {}
242
+ for page_name, texts in pages_texts.items():
243
+ result[page_name] = self.translate_batch(texts, source, target)
244
+ return result
245
+
246
+
247
+ # Convenience function for batch size of 10 pages
248
+ def translate_manga_batch(
249
+ pages_texts: Dict[str, List[str]],
250
+ api_key: str,
251
+ source_lang: str = "ja",
252
+ target_lang: str = "en",
253
+ custom_prompt: str = None,
254
+ batch_size: int = 10
255
+ ) -> Dict[str, List[str]]:
256
+ """
257
+ Translate manga pages in batches of 10.
258
+
259
+ Args:
260
+ pages_texts: All pages' texts
261
+ api_key: Gemini API key
262
+ source_lang: Source language code (ja, zh, ko, etc.)
263
+ target_lang: Target language code
264
+ custom_prompt: Custom style instructions
265
+ batch_size: Number of pages per API call (default: 10)
266
+
267
+ Returns:
268
+ All translated texts
269
+ """
270
+ translator = GeminiTranslator(api_key, custom_prompt=custom_prompt)
271
+
272
+ page_names = list(pages_texts.keys())
273
+ all_results = {}
274
+
275
+ # Process in batches
276
+ for i in range(0, len(page_names), batch_size):
277
+ batch_pages = page_names[i:i + batch_size]
278
+ batch_texts = {name: pages_texts[name] for name in batch_pages}
279
+
280
+ print(f"Translating pages {i+1} to {min(i+batch_size, len(page_names))}...")
281
+ batch_results = translator.translate_pages_batch(
282
+ batch_texts,
283
+ source=source_lang,
284
+ target=target_lang
285
+ )
286
+ all_results.update(batch_results)
287
+
288
+ return all_results
translator/test_translator.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from translator import MangaTranslator
3
+
4
+
5
+ JA_TEXT = "こんばんわ!"
6
+ EN_TRANSLATION = "good evening!"
7
+
8
+
9
+ @pytest.fixture
10
+ def translator():
11
+ return MangaTranslator()
12
+
13
+
14
+ @pytest.mark.parametrize("method", ["google", "hf", "baidu", "bing"])
15
+ def test_translate(translator, method):
16
+ translated_text = translator.translate(JA_TEXT, method=method)
17
+ assert translated_text.lower() == EN_TRANSLATION
18
+
19
+
20
+ def test_invalid_translation_method(translator):
21
+ with pytest.raises(ValueError) as e:
22
+ translator.translate(JA_TEXT, method="Mirai")
23
+ assert str(e.value) == "Invalid translation method."
translator/translator.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from deep_translator import GoogleTranslator
2
+ from transformers import pipeline, AutoModelForSeq2SeqLM, AutoTokenizer
3
+ import translators as ts
4
+ import torch
5
+
6
+
7
+ class MangaTranslator:
8
+ # NLLB language codes mapping
9
+ NLLB_LANG_CODES = {
10
+ "ja": "jpn_Jpan", # Japanese
11
+ "en": "eng_Latn", # English
12
+ "vi": "vie_Latn", # Vietnamese
13
+ "zh": "zho_Hans", # Chinese Simplified
14
+ "ko": "kor_Hang", # Korean
15
+ "th": "tha_Thai", # Thai
16
+ "id": "ind_Latn", # Indonesian
17
+ "fr": "fra_Latn", # French
18
+ "de": "deu_Latn", # German
19
+ "es": "spa_Latn", # Spanish
20
+ "ru": "rus_Cyrl", # Russian
21
+ }
22
+
23
+ def __init__(self, source="ja", target="en", gemini_api_key=None):
24
+ self.target = target
25
+ self.source = source
26
+ self.gemini_api_key = gemini_api_key
27
+ self.translators = {
28
+ "google": self._translate_with_google,
29
+ "hf": self._translate_with_hf,
30
+ "baidu": self._translate_with_baidu,
31
+ "bing": self._translate_with_bing,
32
+ "nllb": self._translate_with_nllb,
33
+ "gemini": self._translate_with_gemini
34
+ }
35
+ # Lazy loading for heavy models
36
+ self._nllb_model = None
37
+ self._nllb_tokenizer = None
38
+ self._gemini_translator = None
39
+
40
+ def set_languages(self, source=None, target=None):
41
+ """Update source and/or target languages."""
42
+ if source:
43
+ self.source = source
44
+ if target:
45
+ self.target = target
46
+
47
+ def translate(self, text, method="google"):
48
+ """
49
+ Translates the given text to the target language using the specified method.
50
+
51
+ Args:
52
+ text (str): The text to be translated.
53
+ method (str):"google" for Google Translator,
54
+ "hf" for Helsinki-NLP's opus-mt-ja-en model (HF pipeline)
55
+ "baidu" for Baidu Translate
56
+ "bing" for Microsoft Bing Translator
57
+ "nllb" for Meta's NLLB-200 model (offline, 200+ languages)
58
+
59
+ Returns:
60
+ str: The translated text.
61
+ """
62
+ translator_func = self.translators.get(method)
63
+
64
+ if translator_func:
65
+ return translator_func(self._preprocess_text(text))
66
+ else:
67
+ raise ValueError("Invalid translation method.")
68
+
69
+ def _translate_with_google(self, text):
70
+ translator = GoogleTranslator(source=self.source, target=self.target)
71
+ translated_text = translator.translate(text)
72
+ return translated_text if translated_text is not None else text
73
+
74
+ def _translate_with_hf(self, text):
75
+ # Lazy load HF pipeline (cache it like NLLB)
76
+ if not hasattr(self, '_hf_pipeline') or self._hf_pipeline is None:
77
+ print("Loading HuggingFace translation model (first time)...")
78
+ self._hf_pipeline = pipeline("translation", model="Helsinki-NLP/opus-mt-ja-en")
79
+ print("HF pipeline loaded and cached!")
80
+
81
+ translated_text = self._hf_pipeline(text)[0]["translation_text"]
82
+ return translated_text if translated_text is not None else text
83
+
84
+ def _translate_with_baidu(self, text):
85
+ translated_text = ts.translate_text(text, translator="baidu",
86
+ from_language="jp",
87
+ to_language=self.target)
88
+ return translated_text if translated_text is not None else text
89
+
90
+ def _translate_with_bing(self, text):
91
+ translated_text = ts.translate_text(text, translator="bing",
92
+ from_language=self.source,
93
+ to_language=self.target)
94
+ return translated_text if translated_text is not None else text
95
+
96
+ def _load_nllb_model(self):
97
+ """Lazy load NLLB model only when first needed (saves memory)"""
98
+ if self._nllb_model is None:
99
+ print("Loading NLLB model (first time, may take a moment)...")
100
+ model_name = "facebook/nllb-200-distilled-600M"
101
+ self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_name)
102
+ self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
103
+ # Force CPU for stability
104
+ self._nllb_model = self._nllb_model.to("cpu")
105
+ self._nllb_model.eval()
106
+ print("NLLB model loaded successfully!")
107
+
108
+ def _translate_with_nllb(self, text):
109
+ """
110
+ Translate using Meta's NLLB-200 model.
111
+ Supports 200+ languages, works offline, optimized for CPU.
112
+ """
113
+ try:
114
+ self._load_nllb_model()
115
+
116
+ # Get NLLB language codes
117
+ src_lang = self.NLLB_LANG_CODES.get(self.source, "jpn_Jpan")
118
+ tgt_lang = self.NLLB_LANG_CODES.get(self.target, "eng_Latn")
119
+
120
+ # Set source language
121
+ self._nllb_tokenizer.src_lang = src_lang
122
+
123
+ # Tokenize
124
+ inputs = self._nllb_tokenizer(text, return_tensors="pt", padding=True)
125
+
126
+ # Generate translation
127
+ with torch.no_grad():
128
+ translated_tokens = self._nllb_model.generate(
129
+ **inputs,
130
+ forced_bos_token_id=self._nllb_tokenizer.convert_tokens_to_ids(tgt_lang),
131
+ max_length=256
132
+ )
133
+
134
+ # Decode
135
+ translated_text = self._nllb_tokenizer.batch_decode(
136
+ translated_tokens, skip_special_tokens=True
137
+ )[0]
138
+
139
+ return translated_text if translated_text else text
140
+
141
+ except Exception as e:
142
+ print(f"NLLB translation error: {e}")
143
+ return text
144
+
145
+ def _translate_with_gemini(self, text):
146
+ """
147
+ Translate using Google Gemini 2.5 Flash-Lite.
148
+ For batch processing, use GeminiTranslator directly.
149
+ """
150
+ try:
151
+ if self._gemini_translator is None:
152
+ from .gemini_translator import GeminiTranslator
153
+ api_key = self.gemini_api_key or "AIzaSyAplFKOKBEcQku5m6gPEBMlZMGc4sI5rgo"
154
+ custom_prompt = getattr(self, '_gemini_custom_prompt', None)
155
+ self._gemini_translator = GeminiTranslator(
156
+ api_key=api_key,
157
+ custom_prompt=custom_prompt
158
+ )
159
+ print(f"Gemini translator initialized! (source={self.source}, target={self.target})")
160
+
161
+ return self._gemini_translator.translate_single(
162
+ text,
163
+ source=self.source,
164
+ target=self.target
165
+ )
166
+ except Exception as e:
167
+ print(f"Gemini translation error: {e}")
168
+ return text
169
+
170
+ def _preprocess_text(self, text):
171
+ preprocessed_text = text.replace(".", ".")
172
+ return preprocessed_text