asierfg794 commited on
Commit
079a3bb
·
1 Parent(s): a4c0cb6

EasyOCR + Traductor (ctranslate2 int8)

Browse files
Files changed (3) hide show
  1. Dockerfile +9 -0
  2. app.py +95 -47
  3. requirements.txt +2 -1
Dockerfile CHANGED
@@ -14,6 +14,15 @@ WORKDIR /app
14
  COPY requirements.txt .
15
  RUN pip install --no-cache-dir -r requirements.txt
16
 
 
 
 
 
 
 
 
 
 
17
  # Aplikazioaren kodea kopiatu
18
  COPY app.py .
19
 
 
14
  COPY requirements.txt .
15
  RUN pip install --no-cache-dir -r requirements.txt
16
 
17
+ # NLLB-200 CTranslate2 formatura bihurtu (INT8 kuantizazioa CPUrako)
18
+ # Build-denboran egiten da: irudia handiagoa baina abiaraztea askoz azkarragoa
19
+ RUN python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('facebook/nllb-200-distilled-600M')" && \
20
+ ct2-transformers-converter \
21
+ --model facebook/nllb-200-distilled-600M \
22
+ --output_dir /app/nllb-200-distilled-600M-ct2-int8 \
23
+ --quantization int8 \
24
+ --force
25
+
26
  # Aplikazioaren kodea kopiatu
27
  COPY app.py .
28
 
app.py CHANGED
@@ -3,6 +3,7 @@ app.py — FastAPI + EasyOCR + Gemini + NLLB + HiTZ zerbitzaria
3
  OCR + postzuzenketa (Gemini 2.5 Flash) + itzulpena (NLLB-200 + HiTZ Marian).
4
  """
5
 
 
6
  import io
7
  import logging
8
  import os
@@ -10,22 +11,18 @@ import re
10
  import time
11
  from contextlib import asynccontextmanager
12
 
 
13
  import easyocr
14
  import httpx
15
  import numpy as np
16
- import torch
17
  from deskew import determine_skew
18
  from fastapi import FastAPI, File, Form, HTTPException, UploadFile
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.responses import JSONResponse
21
  from PIL import Image
22
  from skimage.transform import rotate
23
- from transformers import (
24
- AutoModelForSeq2SeqLM,
25
- AutoTokenizer,
26
- MarianMTModel,
27
- MarianTokenizer,
28
- )
29
 
30
  logging.basicConfig(level=logging.INFO)
31
  logger = logging.getLogger(__name__)
@@ -41,7 +38,7 @@ SCRIPTS = {
41
  "korean": ["en","ko"],
42
  }
43
 
44
- MAX_SIDE = 1500
45
 
46
  GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
47
  GEMINI_MODEL = "gemini-2.5-flash"
@@ -77,6 +74,11 @@ Corrected text:"""
77
  readers: dict = {}
78
 
79
  NLLB_MODEL_NAME = "facebook/nllb-200-distilled-600M"
 
 
 
 
 
80
 
81
  HITZ_PAIRS = {
82
  ("en", "eu"): "HiTZ/mt-hitz-en-eu",
@@ -140,7 +142,7 @@ ISO_TO_NLLB = {
140
  "zu": "zul_Latn",
141
  }
142
 
143
- nllb_model = None
144
  nllb_tokenizer = None
145
  hitz_models: dict = {}
146
 
@@ -299,8 +301,29 @@ def _rebuild(blocks_in, translations):
299
  return "\n".join(out_paragraphs)
300
 
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  def _nllb_translate(text: str, src_nllb: str, tgt_nllb: str) -> str:
303
- """NLLB-200 ereduarekin itzuli, esaldika eta batch-ean."""
304
  if not text.strip():
305
  return text
306
  blocks = _flatten_to_sentences(text)
@@ -308,36 +331,51 @@ def _nllb_translate(text: str, src_nllb: str, tgt_nllb: str) -> str:
308
  if not to_translate:
309
  return text
310
 
311
- logger.info("[NLLB] %s -> %s | %d esaldi", src_nllb, tgt_nllb, len(to_translate))
312
  t0 = time.time()
 
313
  nllb_tokenizer.src_lang = src_nllb
314
- forced_bos = nllb_tokenizer.convert_tokens_to_ids(tgt_nllb)
315
- logger.info("[NLLB] forced_bos_token_id(%s) = %s", tgt_nllb, forced_bos)
 
 
 
 
 
 
 
 
 
 
316
 
317
- translations = []
318
  BATCH = 8
319
- for i in range(0, len(to_translate), BATCH):
320
- chunk = to_translate[i:i + BATCH]
321
- inputs = nllb_tokenizer(
322
- chunk, return_tensors="pt", padding=True,
323
- truncation=True, max_length=512,
 
 
 
 
 
 
 
 
 
324
  )
325
- with torch.no_grad():
326
- outputs = nllb_model.generate(
327
- **inputs,
328
- forced_bos_token_id=forced_bos,
329
- max_new_tokens=512,
330
- num_beams=2,
331
- no_repeat_ngram_size=3,
332
- early_stopping=True,
333
- )
334
- decoded = nllb_tokenizer.batch_decode(outputs, skip_special_tokens=True)
335
- for src_s, out_s in zip(chunk, decoded):
336
- logger.info("[NLLB] %r -> %r", src_s[:60], out_s[:60])
337
- translations.extend(decoded)
338
-
339
- logger.info("[NLLB] Egina %.1fs-tan", time.time() - t0)
340
- return _rebuild(blocks, [t.strip() for t in translations])
341
 
342
 
343
  def _hitz_translate(text: str, src: str, tgt: str) -> str:
@@ -393,17 +431,22 @@ def translate(text: str, src: str, tgt: str) -> str:
393
  @asynccontextmanager
394
  async def lifespan(app: FastAPI):
395
  for name, langs in SCRIPTS.items():
396
- logger.info("Reader kargatzen: %s %s", name, langs)
397
- readers[name] = easyocr.Reader(langs, gpu=False)
398
 
399
- global nllb_model, nllb_tokenizer
400
- logger.info("[LOAD] NLLB eredua kargatzen: %s", NLLB_MODEL_NAME)
401
  nllb_tokenizer = AutoTokenizer.from_pretrained(NLLB_MODEL_NAME)
402
- nllb_model = AutoModelForSeq2SeqLM.from_pretrained(NLLB_MODEL_NAME)
403
- nllb_model.eval()
404
- logger.info("[LOAD] NLLB mota: %s | tokenizer: %s",
405
- nllb_model.__class__.__name__,
406
- nllb_tokenizer.__class__.__name__)
 
 
 
 
 
407
 
408
  # HiTZ aldi baterako desaktibatuta (transformers bateragarritasun arazoak)
409
  logger.info("[LOAD] HiTZ karga saltatzen (NLLB soilik modua)")
@@ -417,6 +460,7 @@ async def lifespan(app: FastAPI):
417
  yield
418
  readers.clear()
419
  hitz_models.clear()
 
420
 
421
 
422
  app = FastAPI(title="OCR + Itzulpena API", version="16.0.0", lifespan=lifespan)
@@ -434,8 +478,9 @@ async def health_check():
434
  "status": "ok",
435
  "scripts": list(readers.keys()),
436
  "gemini": bool(GEMINI_API_KEY),
437
- "nllb": nllb_model is not None,
438
- "nllb_class": nllb_model.__class__.__name__ if nllb_model else None,
 
439
  "hitz_pairs": [f"{s}-{t}" for (s, t) in hitz_models.keys()],
440
  }
441
 
@@ -461,7 +506,10 @@ async def predict(
461
  img_array = np.array(pil_image)
462
  img_array = _deskew(img_array)
463
  reader = readers[script]
464
- results = reader.readtext(img_array, detail=1, paragraph=False)
 
 
 
465
  raw_text = _group_into_lines(results)
466
  logger.info("[OCR] Egina %.1fs-tan, %d karaktere", time.time() - t0, len(raw_text))
467
 
 
3
  OCR + postzuzenketa (Gemini 2.5 Flash) + itzulpena (NLLB-200 + HiTZ Marian).
4
  """
5
 
6
+ import asyncio
7
  import io
8
  import logging
9
  import os
 
11
  import time
12
  from contextlib import asynccontextmanager
13
 
14
+ import ctranslate2
15
  import easyocr
16
  import httpx
17
  import numpy as np
18
+ import torch # HiTZ Marian-erako bakarrik (oraindik desaktibatuta)
19
  from deskew import determine_skew
20
  from fastapi import FastAPI, File, Form, HTTPException, UploadFile
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.responses import JSONResponse
23
  from PIL import Image
24
  from skimage.transform import rotate
25
+ from transformers import AutoTokenizer
 
 
 
 
 
26
 
27
  logging.basicConfig(level=logging.INFO)
28
  logger = logging.getLogger(__name__)
 
38
  "korean": ["en","ko"],
39
  }
40
 
41
+ MAX_SIDE = 1280
42
 
43
  GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
44
  GEMINI_MODEL = "gemini-2.5-flash"
 
74
  readers: dict = {}
75
 
76
  NLLB_MODEL_NAME = "facebook/nllb-200-distilled-600M"
77
+ NLLB_CT2_DIR = os.environ.get(
78
+ "NLLB_CT2_DIR", "/app/nllb-200-distilled-600M-ct2-int8"
79
+ )
80
+ CT2_INTRA_THREADS = int(os.environ.get("CT2_INTRA_THREADS", "2"))
81
+ CT2_INTER_THREADS = int(os.environ.get("CT2_INTER_THREADS", "1"))
82
 
83
  HITZ_PAIRS = {
84
  ("en", "eu"): "HiTZ/mt-hitz-en-eu",
 
142
  "zu": "zul_Latn",
143
  }
144
 
145
+ nllb_translator: ctranslate2.Translator | None = None
146
  nllb_tokenizer = None
147
  hitz_models: dict = {}
148
 
 
301
  return "\n".join(out_paragraphs)
302
 
303
 
304
+ def _adaptive_max_tokens(sentence: str) -> int:
305
+ """Token-kopuru maximoa estimatu sarrera-luzeraren arabera."""
306
+ approx_src_tokens = max(8, len(sentence) // 4)
307
+ return min(512, max(32, int(approx_src_tokens * 1.8)))
308
+
309
+
310
+ _TRANSLATION_CACHE: "dict[tuple[str, str, str], str]" = {}
311
+ _TRANSLATION_CACHE_MAX = 1024
312
+
313
+
314
+ def _cache_get(sentence: str, src: str, tgt: str) -> str | None:
315
+ return _TRANSLATION_CACHE.get((sentence, src, tgt))
316
+
317
+
318
+ def _cache_put(sentence: str, src: str, tgt: str, value: str) -> None:
319
+ if len(_TRANSLATION_CACHE) >= _TRANSLATION_CACHE_MAX:
320
+ # FIFO sinplea: lehen sartutakoa kendu
321
+ _TRANSLATION_CACHE.pop(next(iter(_TRANSLATION_CACHE)))
322
+ _TRANSLATION_CACHE[(sentence, src, tgt)] = value
323
+
324
+
325
  def _nllb_translate(text: str, src_nllb: str, tgt_nllb: str) -> str:
326
+ """NLLB-200 ereduarekin itzuli CTranslate2 motorra erabiliz."""
327
  if not text.strip():
328
  return text
329
  blocks = _flatten_to_sentences(text)
 
331
  if not to_translate:
332
  return text
333
 
334
+ logger.info("[NLLB-CT2] %s -> %s | %d esaldi", src_nllb, tgt_nllb, len(to_translate))
335
  t0 = time.time()
336
+
337
  nllb_tokenizer.src_lang = src_nllb
338
+ translations: list[str] = [""] * len(to_translate)
339
+
340
+ # 1) Cache-tik bete daitezkeenak bete
341
+ pending: list[tuple[int, str]] = []
342
+ cache_hits = 0
343
+ for i, sentence in enumerate(to_translate):
344
+ cached = _cache_get(sentence, src_nllb, tgt_nllb)
345
+ if cached is not None:
346
+ translations[i] = cached
347
+ cache_hits += 1
348
+ else:
349
+ pending.append((i, sentence))
350
 
351
+ # 2) Falta direnak batch-ean itzuli CTranslate2-rekin
352
  BATCH = 8
353
+ for batch_start in range(0, len(pending), BATCH):
354
+ chunk = pending[batch_start:batch_start + BATCH]
355
+ chunk_sentences = [s for _, s in chunk]
356
+ source_tokens_batch = [
357
+ nllb_tokenizer.convert_ids_to_tokens(nllb_tokenizer.encode(s))
358
+ for s in chunk_sentences
359
+ ]
360
+ max_len = max(_adaptive_max_tokens(s) for s in chunk_sentences)
361
+ results = nllb_translator.translate_batch(
362
+ source_tokens_batch,
363
+ target_prefix=[[tgt_nllb]] * len(chunk_sentences),
364
+ beam_size=1,
365
+ max_decoding_length=max_len,
366
+ no_repeat_ngram_size=3,
367
  )
368
+ for (idx, src_s), res in zip(chunk, results):
369
+ target_ids = nllb_tokenizer.convert_tokens_to_ids(res.hypotheses[0][1:])
370
+ out_s = nllb_tokenizer.decode(target_ids, skip_special_tokens=True).strip()
371
+ translations[idx] = out_s
372
+ _cache_put(src_s, src_nllb, tgt_nllb, out_s)
373
+ logger.info("[NLLB-CT2] %r -> %r", src_s[:60], out_s[:60])
374
+
375
+ logger.info("[NLLB-CT2] Egina %.1fs-tan (cache hits: %d/%d, tamaina: %d)",
376
+ time.time() - t0, cache_hits, len(to_translate),
377
+ len(_TRANSLATION_CACHE))
378
+ return _rebuild(blocks, translations)
 
 
 
 
 
379
 
380
 
381
  def _hitz_translate(text: str, src: str, tgt: str) -> str:
 
431
  @asynccontextmanager
432
  async def lifespan(app: FastAPI):
433
  for name, langs in SCRIPTS.items():
434
+ logger.info("Reader kargatzen (quantize=True): %s %s", name, langs)
435
+ readers[name] = easyocr.Reader(langs, gpu=False, quantize=True)
436
 
437
+ global nllb_translator, nllb_tokenizer
438
+ logger.info("[LOAD] NLLB tokenizer kargatzen: %s", NLLB_MODEL_NAME)
439
  nllb_tokenizer = AutoTokenizer.from_pretrained(NLLB_MODEL_NAME)
440
+ logger.info("[LOAD] NLLB CTranslate2 (INT8) kargatzen: %s", NLLB_CT2_DIR)
441
+ nllb_translator = ctranslate2.Translator(
442
+ NLLB_CT2_DIR,
443
+ device="cpu",
444
+ compute_type="int8",
445
+ intra_threads=CT2_INTRA_THREADS,
446
+ inter_threads=CT2_INTER_THREADS,
447
+ )
448
+ logger.info("[LOAD] NLLB-CT2 prest | intra=%d inter=%d",
449
+ CT2_INTRA_THREADS, CT2_INTER_THREADS)
450
 
451
  # HiTZ aldi baterako desaktibatuta (transformers bateragarritasun arazoak)
452
  logger.info("[LOAD] HiTZ karga saltatzen (NLLB soilik modua)")
 
460
  yield
461
  readers.clear()
462
  hitz_models.clear()
463
+ _TRANSLATION_CACHE.clear()
464
 
465
 
466
  app = FastAPI(title="OCR + Itzulpena API", version="16.0.0", lifespan=lifespan)
 
478
  "status": "ok",
479
  "scripts": list(readers.keys()),
480
  "gemini": bool(GEMINI_API_KEY),
481
+ "nllb": nllb_translator is not None,
482
+ "nllb_backend": "ctranslate2-int8",
483
+ "translation_cache_size": len(_TRANSLATION_CACHE),
484
  "hitz_pairs": [f"{s}-{t}" for (s, t) in hitz_models.keys()],
485
  }
486
 
 
506
  img_array = np.array(pil_image)
507
  img_array = _deskew(img_array)
508
  reader = readers[script]
509
+ loop = asyncio.get_running_loop()
510
+ results = await loop.run_in_executor(
511
+ None, lambda: reader.readtext(img_array, detail=1, paragraph=False)
512
+ )
513
  raw_text = _group_into_lines(results)
514
  logger.info("[OCR] Egina %.1fs-tan, %d karaktere", time.time() - t0, len(raw_text))
515
 
requirements.txt CHANGED
@@ -10,4 +10,5 @@ httpx==0.27.0
10
  transformers==4.37.0
11
  torch==2.2.2
12
  sentencepiece==0.2.0
13
- sacremoses==0.1.1
 
 
10
  transformers==4.37.0
11
  torch==2.2.2
12
  sentencepiece==0.2.0
13
+ sacremoses==0.1.1
14
+ ctranslate2==4.3.1