hoangthiencm commited on
Commit
0b6acc2
·
verified ·
1 Parent(s): d9c305d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +117 -84
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
3
- Phiên bản: 8.0 (Content-Based Stitching OCR - Overlap Algorithm)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
@@ -33,6 +33,16 @@ except ImportError:
33
  except OSError:
34
  print("CRITICAL WARNING: pandoc binary not found in system path.")
35
 
 
 
 
 
 
 
 
 
 
 
36
  # --- SUPABASE ---
37
  try:
38
  from supabase import create_client, Client
@@ -47,7 +57,7 @@ GEMINI_API_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
47
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
48
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
49
  SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
50
- MAX_THREADS = int(os.getenv("MAX_THREADS", "5")) # Tăng thread để xử lý các mảnh cắt song song
51
  ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
52
 
53
  # Setup Supabase
@@ -58,7 +68,7 @@ if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
58
  except Exception as e:
59
  print(f"Warning: Không thể kết nối Supabase: {e}")
60
 
61
- app = FastAPI(title="HT_MATH_WEB API", version="8.0")
62
 
63
  app.add_middleware(
64
  CORSMiddleware,
@@ -112,82 +122,61 @@ def check_rate_limit(request: Request):
112
  print(f"[RateLimit] IP {client_ip} requesting too fast.")
113
  ip_rate_limits[client_ip] = now
114
 
115
- # ===== PROMPTS =====
116
- DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""**TRÍCH XUẤT VĂN BẢN THUẦN TÚY**
117
- ⚠️ YÊU CẦU BẮT BUỘC:
118
- - PHẢI trích xuất TOÀN BỘ nội dung xuất hiện trong ảnh/PDF
119
- - KHÔNG được bỏ sót bất kỳ câu hỏi, ví dụ, bài tập nào
120
- - KỂ CẢ các câu nhỏ, câu phụ, chữ mờ, chữ sát lề
121
- - Nếu không chắc, VẪN PHẢI ghi lại nội dung nhìn thấy
122
- ⚠️ NHIỆM VỤ:
123
- 1. Trích xuất toàn bộ văn bản, giữ nguyên định dạng gốc.
124
- 2. KHÔNG sử dụng LaTeX ($...$), giữ nguyên biểu thức toán dạng text (ví dụ: x^2 + 1 = 0).
125
- 3. Đánh dấu tiêu đề bằng Markdown **đậm**.
126
- 4. KHÔNG thêm lời giải thích.
127
- """
128
 
129
- DIRECT_GEMINI_PROMPT_LATEX = r"""Bạn là công cụ trích xuất văn bản từ ảnh/PDF. NHIỆM VỤ: Chuyển đổi nội dung trong ảnh sang Markdown với công thức LaTeX.
130
- ⚠️ YÊU CẦU CỐT LÕI - KHÔNG ĐƯỢC BỎ SÓT:
131
- - Đọc kỹ từng pixel, trích xuất TOÀN BỘ nội dung từ trên xuống dưới.
132
- - KHÔNG bỏ qua bất kỳ bài tập, hình vẽ, hoặc ghi chú nhỏ nào.
133
- - Nếu ảnh bị cắt ngang chữ, hãy cố gắng đoán và hoàn thiện từ đó dựa trên ngữ cảnh.
 
 
 
134
 
135
- ⚠️ QUY TẮC LATEX (BẮT BUỘC):
136
- 1. Mọi công thức toán PHẢI bọc trong dấu $. dụ: $x^2$, $\frac{1}{2}$.
137
- 2. KHÔNG dùng \[...\] hoặc \(...\).
138
- 3. Luôn có khoảng trắng trước dấu $: "Cho hàm số $f(x)$..." (Đúng).
139
 
140
- ⚠️ ĐỊNH DẠNG:
141
- - Giữ nguyên cấu trúc dòng, đoạn.
142
- - Tiêu đề in đậm: **Câu 1:**, **Bài tập:**.
143
- - Bảng biểu giữ nguyên Markdown Table.
 
144
 
145
- CHỈ TRẢ VỀ MARKDOWN. KHÔNG GIẢI THÍCH THÊM.
146
  """
147
 
148
- # ===== STITCHING ALGORITHM (QUAN TRỌNG) =====
149
  def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
150
- """
151
- Thuật toán ghép nối nội dung dựa trên sự trùng lặp (Content-Based Stitching).
152
- So sánh phần đuôi của text_a và phần đầu của text_b để tìm đoạn trùng khớp nhất.
153
- """
154
  if not text_a: return text_b
155
  if not text_b: return text_a
156
 
157
  a_lines = text_a.splitlines()
158
  b_lines = text_b.splitlines()
159
 
160
- # Chỉ so sánh N dòng cuối của A và N dòng đầu của B để tối ưu hiệu năng
161
- # (Tránh việc so sánh toàn bộ văn bản nếu văn bản quá dài)
162
  scan_window = min(len(a_lines), len(b_lines), 30)
163
-
164
  best_overlap_idx = 0
165
 
166
- # Quét từ overlap lớn nhất (scan_window) về 1
167
  for i in range(scan_window, 0, -1):
168
- # Lấy i dòng cuối của A
169
  tail_a = "\n".join(a_lines[-i:]).strip()
170
- # Lấy i dòng đầu của B
171
  head_b = "\n".join(b_lines[:i]).strip()
172
 
173
- # Kiểm tra độ dài tối thiểu và nội dung trùng khớp
174
- # .strip() giúp bỏ qua sự khác biệt về khoảng trắng thừa
175
  if len(tail_a) >= min_overlap_chars and tail_a == head_b:
176
  best_overlap_idx = i
177
- break # Tìm thấy overlap lớn nhất thì dừng ngay (Greedy)
178
 
179
  if best_overlap_idx > 0:
180
- # Tìm thấy overlap: Ghép A + B (bỏ đi phần đầu trùng lặp của B)
181
- # print(f"[Stitch] Found overlap: {best_overlap_idx} lines")
182
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
183
  else:
184
- # Không tìm thấy overlap: Nối bình thường với dòng trống
185
- # print("[Stitch] No overlap found, simple join")
186
  return text_a + "\n\n" + text_b
187
 
188
  # ===== HELPER FUNCTIONS =====
189
  def clean_latex_formulas(text: str) -> str:
190
- return re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
 
 
 
191
 
192
  def hash_password(password: str) -> str:
193
  return hashlib.sha256(password.encode()).hexdigest()
@@ -195,21 +184,69 @@ def hash_password(password: str) -> str:
195
  def verify_password(password: str, hashed: str) -> bool:
196
  return hash_password(password) == hashed
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  # ===== API ENDPOINTS =====
199
 
200
  @app.get("/")
201
  @app.get("/health")
202
  async def root():
203
  pandoc_status = "Not Found"
 
204
  try:
205
  pandoc_status = pypandoc.get_pandoc_version()
206
- except:
207
- pass
 
 
 
 
208
  return {
209
  "status": "ok",
210
- "service": "HT_MATH_WEB API v8.0 (Stitching OCR)",
211
  "keys_loaded": key_manager.get_key_count(),
212
- "pandoc_version": pandoc_status
 
213
  }
214
 
215
  @app.get("/api/models")
@@ -270,61 +307,67 @@ async def upload_image(file: UploadFile = File(...)):
270
  # --- CORE CONVERT LOGIC ---
271
 
272
  async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, max_retries: int = 3) -> str:
273
- """Gửi 1 ảnh (hoặc mảnh ảnh) lên Gemini và nhận text"""
274
  for attempt in range(max_retries):
275
  try:
276
  api_key = key_manager.get_next_key()
277
  if not api_key: raise ValueError("No API Key")
278
  genai.configure(api_key=api_key)
279
 
280
- generation_config = {"temperature": 0.0, "top_p": 1.0, "max_output_tokens": 8192}
281
  model = genai.GenerativeModel(model_id, generation_config=generation_config)
 
 
282
  response = model.generate_content([prompt, image])
283
 
284
- if response.text:
285
- return response.text.strip()
 
 
 
 
 
 
 
 
 
286
  except Exception as e:
287
  if "429" in str(e) and attempt < max_retries - 1:
288
  time.sleep(2)
289
  continue
290
- if attempt == max_retries - 1: raise e
 
 
 
 
291
  return ""
292
 
293
  async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore) -> str:
294
  """
295
- Xử lý ảnh lớn bằng kỹ thuật: Overlap Splitting + Content-Based Stitching
296
  """
297
- # Cấu hình cắt ảnh
298
- CHUNK_HEIGHT = 2048 # Chiều cao mỗi mảnh (pixel)
299
- OVERLAP_HEIGHT = 512 # Chiều cao phần chồng lặp (pixel)
300
 
301
  width, height = image.size
302
 
303
- # Nếu ảnh nhỏ hơn ngưỡng cắt, xử lý bình thường
304
  if height <= CHUNK_HEIGHT:
305
  async with semaphore:
306
  return await process_image_with_gemini(image, model, prompt)
307
 
308
- # --- Cắt ảnh thành các mảnh có Overlap ---
309
  chunks = []
310
  y = 0
311
  while y < height:
312
- # Xác định vùng cắt
313
  bottom = min(y + CHUNK_HEIGHT, height)
314
  box = (0, y, width, bottom)
315
  chunk = image.crop(box)
316
  chunks.append(chunk)
317
-
318
- # Nếu đã đến đáy ảnh thì dừng
319
- if bottom == height:
320
- break
321
-
322
- # Di chuyển y xuống, nhưng lùi lại một đoạn overlap
323
  y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
324
 
325
- print(f"[Split] Image height {height}px -> {len(chunks)} chunks with overlap.")
326
 
327
- # --- Gửi song song các mảnh lên Gemini ---
328
  async def process_chunk(chunk_img, index):
329
  async with semaphore:
330
  text = await process_image_with_gemini(chunk_img, model, prompt)
@@ -333,11 +376,9 @@ async def process_large_image(image: Image.Image, model: str, prompt: str, semap
333
  tasks = [process_chunk(chunk, i) for i, chunk in enumerate(chunks)]
334
  chunk_results = await asyncio.gather(*tasks)
335
 
336
- # Sắp xếp lại theo đúng thứ tự
337
  chunk_results.sort(key=lambda x: x[0])
338
  ordered_texts = [text for _, text in chunk_results]
339
 
340
- # --- Ghép nối thông minh (Stitching) ---
341
  final_text = ordered_texts[0]
342
  for i in range(1, len(ordered_texts)):
343
  final_text = stitch_text(final_text, ordered_texts[i], min_overlap_chars=20)
@@ -361,21 +402,15 @@ async def convert_file(
361
  file_content = await file.read()
362
  file_ext = os.path.splitext(file.filename)[1].lower()
363
 
364
- # Global semaphore để kiểm soát tổng số request đồng thời lên Gemini
365
- # Tránh lỗi 429 Quota Exceeded khi cắt quá nhiều mảnh
366
  global_semaphore = asyncio.Semaphore(MAX_THREADS)
367
 
368
  results = []
369
 
370
  if file_ext == ".pdf":
371
  doc = fitz.open(stream=file_content, filetype="pdf")
372
-
373
- # Hàm xử lý từng trang (có thể cắt nhỏ bên trong nếu trang dài)
374
  async def process_page_wrapper(page, idx):
375
- # Render ảnh chất lượng cao
376
  pix = page.get_pixmap(dpi=300)
377
  img = Image.open(io.BytesIO(pix.tobytes("png")))
378
- # Gọi hàm xử lý ảnh lớn (tự động cắt/ghép nếu cần)
379
  text = await process_large_image(img, model, prompt, global_semaphore)
380
  return idx, text
381
 
@@ -386,7 +421,6 @@ async def convert_file(
386
 
387
  elif file_ext in [".png", ".jpg", ".jpeg", ".bmp"]:
388
  img = Image.open(io.BytesIO(file_content))
389
- # Xử lý ảnh upload (tự động cắt/ghép nếu dài)
390
  text = await process_large_image(img, model, prompt, global_semaphore)
391
  results.append(text)
392
  else:
@@ -400,7 +434,6 @@ async def convert_file(
400
  traceback.print_exc()
401
  raise HTTPException(status_code=500, detail=str(e))
402
 
403
- # --- WORD EXPORT API (PANDOC NATIVE) ---
404
  @app.post("/api/export-docx")
405
  async def export_docx(markdown_text: str = Form(...)):
406
  try:
@@ -418,7 +451,7 @@ async def export_docx(markdown_text: str = Form(...)):
418
  return FileResponse(
419
  output_filename,
420
  media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
421
- filename="Ket_qua_HT_MATH_Pandoc.docx"
422
  )
423
  except Exception as e:
424
  import traceback
 
1
  """
2
  Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
3
+ Phiên bản: 9.0 (Copyright Safety & Fallback OCR)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
 
33
  except OSError:
34
  print("CRITICAL WARNING: pandoc binary not found in system path.")
35
 
36
+ # --- TESSERACT IMPORT (FALLBACK OCR) ---
37
+ try:
38
+ import pytesseract
39
+ # Kiểm tra xem binary có tồn tại không (trong Docker thường ở /usr/bin/tesseract)
40
+ # pytesseract.pytesseract.tesseract_cmd = r'/usr/bin/tesseract'
41
+ print("INFO: Tesseract OCR module loaded.")
42
+ except ImportError:
43
+ print("WARNING: pytesseract not found. Fallback OCR will not work.")
44
+ pytesseract = None
45
+
46
  # --- SUPABASE ---
47
  try:
48
  from supabase import create_client, Client
 
57
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
58
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
59
  SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
60
+ MAX_THREADS = int(os.getenv("MAX_THREADS", "5"))
61
  ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
62
 
63
  # Setup Supabase
 
68
  except Exception as e:
69
  print(f"Warning: Không thể kết nối Supabase: {e}")
70
 
71
+ app = FastAPI(title="HT_MATH_WEB API", version="9.0")
72
 
73
  app.add_middleware(
74
  CORSMiddleware,
 
122
  print(f"[RateLimit] IP {client_ip} requesting too fast.")
123
  ip_rate_limits[client_ip] = now
124
 
125
+ # ===== SAFE PROMPTS (ANTI-COPYRIGHT) =====
126
+ # Đã đổi prompt để tránh trigger bản quyền (finish_reason=4)
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""Bạn là trợ AI hỗ trợ người khiếm thị số hóa tài liệu.
129
+ Nhiệm vụ: Nhận diện tả lại nội dung văn bản trong hình ảnh một cách trung thực, chính xác.
130
+ Yêu cầu:
131
+ - Trích xuất nội dung văn bản để lưu trữ dữ liệu.
132
+ - Giữ nguyên các thông số, dữ liệu toán học.
133
+ - Định dạng Markdown rõ ràng.
134
+ - KHÔNG thêm lời bình luận.
135
+ """
136
 
137
+ DIRECT_GEMINI_PROMPT_LATEX = r"""Bạn trợ lý AI hỗ trợ số hóa tài liệu giáo dục.
138
+ Nhiệm vụ: Phân tích hình ảnh viết lại nội dung dưới dạng Markdown chuẩn Toán học (LaTeX).
139
+ Mục đích: Số hóa để lưu trữ và giảng dạy, không nhằm mục đích sao chép thương mại.
 
140
 
141
+ ⚠️ YÊU CẦU XỬ LÝ AN TOÀN:
142
+ 1. Nếu gặp nội dung giống sách giáo khoa hoặc tài liệu có bản quyền, hãy VIẾT LẠI (paraphrase) lời dẫn nhưng GIỮ NGUYÊN công thức toán học và số liệu.
143
+ 2. Trình bày công thức toán trong dấu `$`. Ví dụ: $x^2 + 2x = 0$.
144
+ 3. Đảm bảo cấu trúc bài tập rõ ràng (Câu 1, Câu 2...).
145
+ 4. Nếu hình ảnh mờ hoặc bị cắt, hãy cố gắng phục hồi dựa trên ngữ cảnh toán học.
146
 
147
+ CHỈ TRẢ VỀ NỘI DUNG MARKDOWN.
148
  """
149
 
150
+ # ===== STITCHING ALGORITHM =====
151
  def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
 
 
 
 
152
  if not text_a: return text_b
153
  if not text_b: return text_a
154
 
155
  a_lines = text_a.splitlines()
156
  b_lines = text_b.splitlines()
157
 
 
 
158
  scan_window = min(len(a_lines), len(b_lines), 30)
 
159
  best_overlap_idx = 0
160
 
 
161
  for i in range(scan_window, 0, -1):
 
162
  tail_a = "\n".join(a_lines[-i:]).strip()
 
163
  head_b = "\n".join(b_lines[:i]).strip()
164
 
 
 
165
  if len(tail_a) >= min_overlap_chars and tail_a == head_b:
166
  best_overlap_idx = i
167
+ break
168
 
169
  if best_overlap_idx > 0:
 
 
170
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
171
  else:
 
 
172
  return text_a + "\n\n" + text_b
173
 
174
  # ===== HELPER FUNCTIONS =====
175
  def clean_latex_formulas(text: str) -> str:
176
+ # Chuẩn hóa khoảng trắng Latex
177
+ text = re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
178
+ # Fix lỗi phổ biến khi OCR tiếng Việt bị dính ký tự
179
+ return text
180
 
181
  def hash_password(password: str) -> str:
182
  return hashlib.sha256(password.encode()).hexdigest()
 
184
  def verify_password(password: str, hashed: str) -> bool:
185
  return hash_password(password) == hashed
186
 
187
+ def safe_get_text(response) -> str:
188
+ """
189
+ Trích xuất text an toàn từ Gemini Response.
190
+ Xử lý trường hợp bị chặn bản quyền (finish_reason=4).
191
+ """
192
+ if not response.candidates:
193
+ return ""
194
+
195
+ candidate = response.candidates[0]
196
+
197
+ # Kiểm tra lý do kết thúc
198
+ # 1 = STOP (OK), 4 = RECITING_FROM_COPYRIGHTED_MATERIAL (Blocked)
199
+ if candidate.finish_reason == 4:
200
+ return "[BLOCKED_BY_COPYRIGHT]"
201
+
202
+ if candidate.finish_reason != 1:
203
+ # Có thể log warning ở đây với các reason khác (như SAFETY)
204
+ return ""
205
+
206
+ # Nếu an toàn, trích xuất text
207
+ parts = candidate.content.parts
208
+ texts = [p.text for p in parts if hasattr(p, "text")]
209
+ return "\n".join(texts)
210
+
211
+ async def fallback_ocr_tesseract(image: Image.Image) -> str:
212
+ """
213
+ Fallback dùng Tesseract OCR khi Gemini từ chối phục vụ
214
+ """
215
+ if pytesseract is None:
216
+ return "**[Lỗi] Gemini từ chối xử lý (Bản quyền) và Tesseract chưa được cài đặt.**"
217
+
218
+ print("[Fallback] Đang chạy Tesseract OCR...")
219
+ try:
220
+ # Chạy trong executor để không block event loop
221
+ loop = asyncio.get_running_loop()
222
+ # Chế độ: tiếng Việt + tiếng Anh + công thức toán (nếu train, ở đây dùng cơ bản)
223
+ text = await loop.run_in_executor(None, lambda: pytesseract.image_to_string(image, lang='vie+eng'))
224
+ return f"**[Lưu ý: Nội dung này được trích xuất bằng OCR dự phòng do Gemini chặn bản quyền]**\n\n{text}"
225
+ except Exception as e:
226
+ print(f"Tesseract Error: {e}")
227
+ return "**[Lỗi] Cả Gemini và Tesseract đều thất bại.**"
228
+
229
  # ===== API ENDPOINTS =====
230
 
231
  @app.get("/")
232
  @app.get("/health")
233
  async def root():
234
  pandoc_status = "Not Found"
235
+ tesseract_status = "Not Found"
236
  try:
237
  pandoc_status = pypandoc.get_pandoc_version()
238
+ except: pass
239
+
240
+ try:
241
+ if pytesseract: tesseract_status = "Ready"
242
+ except: pass
243
+
244
  return {
245
  "status": "ok",
246
+ "service": "HT_MATH_WEB API v9.0 (Safe Mode)",
247
  "keys_loaded": key_manager.get_key_count(),
248
+ "pandoc": pandoc_status,
249
+ "tesseract": tesseract_status
250
  }
251
 
252
  @app.get("/api/models")
 
307
  # --- CORE CONVERT LOGIC ---
308
 
309
  async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, max_retries: int = 3) -> str:
310
+ """Gửi 1 ảnh (hoặc mảnh ảnh) lên Gemini và nhận text, có fallback"""
311
  for attempt in range(max_retries):
312
  try:
313
  api_key = key_manager.get_next_key()
314
  if not api_key: raise ValueError("No API Key")
315
  genai.configure(api_key=api_key)
316
 
317
+ generation_config = {"temperature": 0.2, "top_p": 1.0, "max_output_tokens": 8192}
318
  model = genai.GenerativeModel(model_id, generation_config=generation_config)
319
+
320
+ # Gọi API
321
  response = model.generate_content([prompt, image])
322
 
323
+ # Lấy text an toàn
324
+ text = safe_get_text(response)
325
+
326
+ # XỬ LÝ LỖI BẢN QUYỀN -> FALLBACK
327
+ if text == "[BLOCKED_BY_COPYRIGHT]":
328
+ print(f"Warning: Gemini blocked copyright content. Switching to fallback OCR...")
329
+ return await fallback_ocr_tesseract(image)
330
+
331
+ if text:
332
+ return text.strip()
333
+
334
  except Exception as e:
335
  if "429" in str(e) and attempt < max_retries - 1:
336
  time.sleep(2)
337
  continue
338
+ if attempt == max_retries - 1:
339
+ print(f"Error Gemini: {e}")
340
+ # Nếu lỗi mạng/quota quá nhiều, cũng fallback luôn cho chắc
341
+ return await fallback_ocr_tesseract(image)
342
+
343
  return ""
344
 
345
  async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore) -> str:
346
  """
347
+ Xử lý ảnh lớn: Cắt -> Gửi (kèm fallback) -> Ghép
348
  """
349
+ CHUNK_HEIGHT = 2048
350
+ OVERLAP_HEIGHT = 512
 
351
 
352
  width, height = image.size
353
 
 
354
  if height <= CHUNK_HEIGHT:
355
  async with semaphore:
356
  return await process_image_with_gemini(image, model, prompt)
357
 
358
+ # Cắt ảnh
359
  chunks = []
360
  y = 0
361
  while y < height:
 
362
  bottom = min(y + CHUNK_HEIGHT, height)
363
  box = (0, y, width, bottom)
364
  chunk = image.crop(box)
365
  chunks.append(chunk)
366
+ if bottom == height: break
 
 
 
 
 
367
  y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
368
 
369
+ print(f"[Split] Image height {height}px -> {len(chunks)} chunks.")
370
 
 
371
  async def process_chunk(chunk_img, index):
372
  async with semaphore:
373
  text = await process_image_with_gemini(chunk_img, model, prompt)
 
376
  tasks = [process_chunk(chunk, i) for i, chunk in enumerate(chunks)]
377
  chunk_results = await asyncio.gather(*tasks)
378
 
 
379
  chunk_results.sort(key=lambda x: x[0])
380
  ordered_texts = [text for _, text in chunk_results]
381
 
 
382
  final_text = ordered_texts[0]
383
  for i in range(1, len(ordered_texts)):
384
  final_text = stitch_text(final_text, ordered_texts[i], min_overlap_chars=20)
 
402
  file_content = await file.read()
403
  file_ext = os.path.splitext(file.filename)[1].lower()
404
 
 
 
405
  global_semaphore = asyncio.Semaphore(MAX_THREADS)
406
 
407
  results = []
408
 
409
  if file_ext == ".pdf":
410
  doc = fitz.open(stream=file_content, filetype="pdf")
 
 
411
  async def process_page_wrapper(page, idx):
 
412
  pix = page.get_pixmap(dpi=300)
413
  img = Image.open(io.BytesIO(pix.tobytes("png")))
 
414
  text = await process_large_image(img, model, prompt, global_semaphore)
415
  return idx, text
416
 
 
421
 
422
  elif file_ext in [".png", ".jpg", ".jpeg", ".bmp"]:
423
  img = Image.open(io.BytesIO(file_content))
 
424
  text = await process_large_image(img, model, prompt, global_semaphore)
425
  results.append(text)
426
  else:
 
434
  traceback.print_exc()
435
  raise HTTPException(status_code=500, detail=str(e))
436
 
 
437
  @app.post("/api/export-docx")
438
  async def export_docx(markdown_text: str = Form(...)):
439
  try:
 
451
  return FileResponse(
452
  output_filename,
453
  media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
454
+ filename="HT_MATH_OUTPUT.docx"
455
  )
456
  except Exception as e:
457
  import traceback