mmo9 commited on
Commit
a6d6c69
Β·
verified Β·
1 Parent(s): c27068b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +178 -124
app.py CHANGED
@@ -1,167 +1,221 @@
1
  """
2
- Eagle Eye β€” ZeroGPU OCR API Service (Hugging Face Space)
3
- Model: Qwen/Qwen2.5-VL-3B-Instruct (fast, small, accurate)
 
 
4
 
5
- Speed optimizations:
6
- - True GPU batching: multiple images in one forward pass
7
- - Greedy decoding (no sampling)
8
- - max_new_tokens=128 (enough for speech bubbles)
9
- - float16 weights
10
- - Model loaded once and cached globally
11
-
12
- Upload this file + requirements.txt to your HF Space (ZeroGPU enabled).
13
  """
14
 
15
- import gradio as gr
16
- import spaces
17
- import json
18
  import base64
19
  import io
 
20
  import traceback
 
 
 
 
21
  from PIL import Image
22
 
23
- MODEL = None
24
- PROCESSOR = None
25
- DEVICE = None
26
 
 
 
 
 
27
 
28
- def load_model():
29
- global MODEL, PROCESSOR, DEVICE
30
- if MODEL is not None:
31
- return MODEL, PROCESSOR, DEVICE
32
 
33
- import torch
34
- from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
35
 
36
- model_id = "Qwen/Qwen2.5-VL-3B-Instruct"
37
- PROCESSOR = AutoProcessor.from_pretrained(
38
- model_id,
39
- min_pixels=64 * 28 * 28,
40
- max_pixels=512 * 28 * 28, # cap resolution for speed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  )
42
- MODEL = Qwen2_5_VLForConditionalGeneration.from_pretrained(
43
- model_id,
44
  torch_dtype=torch.float16,
45
  device_map="auto",
46
  ).eval()
47
- DEVICE = next(MODEL.parameters()).device
48
- return MODEL, PROCESSOR, DEVICE
 
49
 
50
 
51
- PROMPT = "Extract all text from this image. Return only the text, no explanations."
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- @spaces.GPU(duration=60)
55
- def ocr_batch(images_b64_json: str) -> str:
56
- """
57
- Receive a JSON list of base64-encoded PNG images.
58
- Returns a JSON list of extracted text strings (same order).
59
- Processes all images in a single GPU forward pass for speed.
60
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  import torch
62
  from qwen_vl_utils import process_vision_info
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  try:
65
- images_b64: list[str] = json.loads(images_b64_json)
66
  except Exception as exc:
67
- return json.dumps({"error": f"Invalid JSON: {exc}"})
68
 
69
- if not images_b64:
70
  return json.dumps([])
71
 
72
- model, processor, device = load_model()
 
73
 
74
- # Decode all images
75
- pil_images: list[Image.Image] = []
76
- decode_errors: dict[int, str] = {}
77
- for idx, b64 in enumerate(images_b64):
78
  try:
79
- img_bytes = base64.b64decode(b64)
80
- pil_images.append(Image.open(io.BytesIO(img_bytes)).convert("RGB"))
81
  except Exception as exc:
82
- pil_images.append(None)
83
- decode_errors[idx] = str(exc)
84
-
85
- # Build per-image messages
86
- texts_in: list[str] = []
87
- image_inputs_all: list = []
88
- valid_indices: list[int] = []
89
-
90
- for idx, img in enumerate(pil_images):
91
- if img is None:
92
  continue
93
- messages = [
94
- {
95
- "role": "user",
96
- "content": [
97
- {"type": "image", "image": img},
98
- {"type": "text", "text": PROMPT},
99
- ],
100
- }
101
- ]
102
- text_prompt = processor.apply_chat_template(
103
- messages, tokenize=False, add_generation_prompt=True
104
- )
105
- image_inputs, _ = process_vision_info(messages)
106
-
107
- texts_in.append(text_prompt)
108
- image_inputs_all.extend(image_inputs)
109
- valid_indices.append(idx)
110
-
111
- if not texts_in:
112
- results = [decode_errors.get(i, "[DECODE_ERROR]") for i in range(len(images_b64))]
113
- return json.dumps(results, ensure_ascii=False)
114
 
115
- # Single batched forward pass
116
- try:
117
- inputs = processor(
118
- text=texts_in,
119
- images=image_inputs_all,
120
- padding=True,
121
- return_tensors="pt",
122
- ).to(device)
123
-
124
- with torch.no_grad():
125
- generated_ids = model.generate(
126
- **inputs,
127
- max_new_tokens=128,
128
- do_sample=False,
129
- pad_token_id=processor.tokenizer.eos_token_id,
130
- )
131
-
132
- # Trim prompt tokens
133
- trimmed = [
134
- out[len(inp):]
135
- for inp, out in zip(inputs["input_ids"], generated_ids)
136
- ]
137
- decoded = processor.batch_decode(
138
- trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
139
- )
140
 
141
- except Exception as exc:
142
- traceback.print_exc()
143
- decoded = [f"[BATCH_ERROR: {exc}]"] * len(valid_indices)
144
 
145
- # Map back to original positions
146
- results = [""] * len(images_b64)
147
- for i, orig_idx in enumerate(valid_indices):
148
- results[orig_idx] = decoded[i].strip() if i < len(decoded) else "[MISSING]"
149
- for idx, err in decode_errors.items():
150
- results[idx] = f"[DECODE_ERROR: {err}]"
151
 
152
- return json.dumps(results, ensure_ascii=False)
153
 
 
154
 
155
  demo = gr.Interface(
156
- fn=ocr_batch,
157
- inputs=gr.Textbox(
158
- label="Base64 Images (JSON array)",
159
- placeholder='["<base64_1>", "<base64_2>", ...]',
160
- lines=3,
161
- ),
162
- outputs=gr.Textbox(label="OCR Results (JSON array)", lines=5),
163
- title="πŸ¦… Eagle Eye β€” Manga OCR (Qwen2.5-VL-3B)",
164
- description="Fast GPU OCR. Send base64 PNG images, get text back.",
165
  flagging_mode="never",
166
  )
167
 
 
1
  """
2
+ Eagle Eye β€” ZeroGPU OCR Service v3 (YOLO + Qwen2.5-VL-7B)
3
+ =============================================================
4
+ Detection : comictextdetector.pt (manga-image-translator YOLO model)
5
+ OCR : Qwen/Qwen2.5-VL-7B-Instruct
6
 
7
+ Input : JSON list of base64-encoded full-page PNG images
8
+ Output : JSON list of text strings (one per page, bubbles separated by \\n)
 
 
 
 
 
 
9
  """
10
 
 
 
 
11
  import base64
12
  import io
13
+ import json
14
  import traceback
15
+
16
+ import gradio as gr
17
+ import numpy as np
18
+ import spaces
19
  from PIL import Image
20
 
21
+ # ── Global model cache (loaded once, kept alive between ZeroGPU calls) ─────
 
 
22
 
23
+ _YOLO = None # ultralytics.YOLO | "fallback"
24
+ _OCR_MODEL = None
25
+ _OCR_PROC = None
26
+ _DEVICE = None
27
 
 
 
 
 
28
 
29
+ # ── Loaders ─────────────────────────────────────────────────────────────────
 
30
 
31
+ def _get_yolo():
32
+ global _YOLO
33
+ if _YOLO is not None:
34
+ return _YOLO
35
+ try:
36
+ from huggingface_hub import hf_hub_download
37
+ from ultralytics import YOLO
38
+ path = hf_hub_download(
39
+ repo_id="zyddnys/manga-image-translator",
40
+ filename="comictextdetector.pt",
41
+ )
42
+ _YOLO = YOLO(path)
43
+ print("βœ… YOLO comic-text-detector loaded")
44
+ except Exception as exc:
45
+ print(f"⚠️ YOLO failed ({exc}) β€” using full-page OCR fallback")
46
+ _YOLO = "fallback"
47
+ return _YOLO
48
+
49
+
50
+ def _get_ocr():
51
+ global _OCR_MODEL, _OCR_PROC, _DEVICE
52
+ if _OCR_MODEL is not None:
53
+ return _OCR_MODEL, _OCR_PROC, _DEVICE
54
+ import torch
55
+ from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
56
+ _OCR_PROC = AutoProcessor.from_pretrained(
57
+ "Qwen/Qwen2.5-VL-7B-Instruct",
58
+ min_pixels=128 * 28 * 28,
59
+ max_pixels=512 * 28 * 28,
60
  )
61
+ _OCR_MODEL = Qwen2_5_VLForConditionalGeneration.from_pretrained(
62
+ "Qwen/Qwen2.5-VL-7B-Instruct",
63
  torch_dtype=torch.float16,
64
  device_map="auto",
65
  ).eval()
66
+ _DEVICE = next(_OCR_MODEL.parameters()).device
67
+ print("βœ… Qwen2.5-VL-7B loaded")
68
+ return _OCR_MODEL, _OCR_PROC, _DEVICE
69
 
70
 
71
+ # ── Detection helpers ────────────────────────────────────────────────────────
72
 
73
+ def _yolo_detect(img: Image.Image, yolo) -> list[tuple[int, int, int, int]]:
74
+ """Return [(x1,y1,x2,y2)] sorted top-to-bottom, right-to-left."""
75
+ if yolo == "fallback":
76
+ return []
77
+ try:
78
+ arr = np.array(img)
79
+ results = yolo(arr, verbose=False, conf=0.25, iou=0.45)
80
+ boxes = []
81
+ for r in results:
82
+ for xyxy in r.boxes.xyxy.cpu().numpy():
83
+ x1, y1, x2, y2 = (int(v) for v in xyxy)
84
+ if (x2 - x1) > 15 and (y2 - y1) > 10:
85
+ boxes.append((x1, y1, x2, y2))
86
+ # Sort: top-to-bottom, right-to-left (manhwa reading order)
87
+ boxes.sort(key=lambda b: (b[1] // 60, -b[0]))
88
+ return boxes
89
+ except Exception as exc:
90
+ print(f"YOLO detect error: {exc}")
91
+ return []
92
 
93
+
94
+ def _safe_crop(img: Image.Image, box: tuple[int, int, int, int]) -> Image.Image | None:
95
+ w, h = img.size
96
+ x1, y1, x2, y2 = box
97
+ x1, y1 = max(0, x1 - 4), max(0, y1 - 4)
98
+ x2, y2 = min(w, x2 + 4), min(h, y2 + 4)
99
+ if x2 - x1 < 10 or y2 - y1 < 10:
100
+ return None
101
+ return img.crop((x1, y1, x2, y2))
102
+
103
+
104
+ # ── OCR helper ───���───────────────────────────────────────────────────────────
105
+
106
+ _BUBBLE_PROMPT = (
107
+ "Extract the text from this manga/manhwa speech bubble. "
108
+ "Output only the raw text, nothing else."
109
+ )
110
+ _PAGE_PROMPT = (
111
+ "This is a manga/manhwa page. Extract ALL text visible (dialogue, captions, SFX). "
112
+ "Output each text bubble or caption on its own line, in reading order. "
113
+ "Output only the text, no labels or explanations."
114
+ )
115
+
116
+
117
+ def _ocr_batch(images: list[Image.Image], model, proc, device,
118
+ is_full_page: bool = False) -> list[str]:
119
+ """Batch-OCR a list of PIL images. Returns one string per image."""
120
+ if not images:
121
+ return []
122
  import torch
123
  from qwen_vl_utils import process_vision_info
124
 
125
+ prompt = _PAGE_PROMPT if is_full_page else _BUBBLE_PROMPT
126
+ texts_in, imgs_flat = [], []
127
+
128
+ for img in images:
129
+ msgs = [{"role": "user", "content": [
130
+ {"type": "image", "image": img},
131
+ {"type": "text", "text": prompt},
132
+ ]}]
133
+ texts_in.append(proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True))
134
+ img_inputs, _ = process_vision_info(msgs)
135
+ imgs_flat.extend(img_inputs)
136
+
137
+ inputs = proc(
138
+ text=texts_in, images=imgs_flat,
139
+ padding=True, return_tensors="pt",
140
+ ).to(device)
141
+
142
+ with torch.no_grad():
143
+ gen = model.generate(
144
+ **inputs,
145
+ max_new_tokens=256,
146
+ do_sample=False,
147
+ pad_token_id=proc.tokenizer.eos_token_id,
148
+ )
149
+
150
+ trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
151
+ return [t.strip() for t in proc.batch_decode(trimmed, skip_special_tokens=True,
152
+ clean_up_tokenization_spaces=False)]
153
+
154
+
155
+ # ── Main endpoint ────────────────────────────────────────────────────────────
156
+
157
+ @spaces.GPU(duration=120)
158
+ def process_pages(pages_b64_json: str) -> str:
159
+ """
160
+ Receive a JSON list of base64-encoded full-page images.
161
+ Returns a JSON list of text strings (one per page).
162
+ """
163
  try:
164
+ pages_b64: list[str] = json.loads(pages_b64_json)
165
  except Exception as exc:
166
+ return json.dumps({"error": f"Bad JSON: {exc}"})
167
 
168
+ if not pages_b64:
169
  return json.dumps([])
170
 
171
+ yolo = _get_yolo()
172
+ model, proc, device = _get_ocr()
173
 
174
+ page_results: list[str] = []
175
+
176
+ for b64 in pages_b64:
 
177
  try:
178
+ page_img = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
 
179
  except Exception as exc:
180
+ page_results.append(f"[DECODE_ERROR: {exc}]")
 
 
 
 
 
 
 
 
 
181
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ try:
184
+ boxes = _yolo_detect(page_img, yolo)
185
+ crops = [c for b in boxes if (c := _safe_crop(page_img, b)) is not None]
186
+
187
+ if crops:
188
+ # Process in sub-batches of 8 to avoid OOM
189
+ SUBBATCH = 8
190
+ all_texts = []
191
+ for i in range(0, len(crops), SUBBATCH):
192
+ all_texts.extend(_ocr_batch(crops[i:i+SUBBATCH], model, proc, device))
193
+ page_text = "\n".join(t for t in all_texts if t)
194
+ else:
195
+ page_text = ""
196
+
197
+ # Always fall back to full-page if result is empty
198
+ if not page_text.strip():
199
+ res = _ocr_batch([page_img], model, proc, device, is_full_page=True)
200
+ page_text = res[0] if res else ""
201
+
202
+ page_results.append(page_text)
 
 
 
 
 
203
 
204
+ except Exception as exc:
205
+ traceback.print_exc()
206
+ page_results.append(f"[PAGE_ERROR: {exc}]")
207
 
208
+ return json.dumps(page_results, ensure_ascii=False)
 
 
 
 
 
209
 
 
210
 
211
+ # ── Gradio UI ────────────────────────────────────────────────────────────────
212
 
213
  demo = gr.Interface(
214
+ fn=process_pages,
215
+ inputs=gr.Textbox(label="Pages JSON (base64 array)", lines=3),
216
+ outputs=gr.Textbox(label="Results JSON (text per page)", lines=10),
217
+ title="πŸ¦… Eagle Eye β€” Manga OCR (YOLO + Qwen2.5-VL-7B)",
218
+ description="Send full manga pages as base64 JSON β†’ get text back.",
 
 
 
 
219
  flagging_mode="never",
220
  )
221