Spaces:
Sleeping
Sleeping
Commit ·
079a3bb
1
Parent(s): a4c0cb6
EasyOCR + Traductor (ctranslate2 int8)
Browse files- Dockerfile +9 -0
- app.py +95 -47
- 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 =
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
BATCH = 8
|
| 319 |
-
for
|
| 320 |
-
chunk =
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 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
|
| 400 |
-
logger.info("[LOAD] NLLB
|
| 401 |
nllb_tokenizer = AutoTokenizer.from_pretrained(NLLB_MODEL_NAME)
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 438 |
-
"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|