File size: 3,795 Bytes
387636a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import io
import os
import re

import numpy as np
from PIL import Image

# Facteur d'agrandissement appliqué aux crops de bulles AVANT l'OCR. Les
# bulles croppées sont souvent petites ; agrandir l'image (LANCZOS) améliore
# nettement la reconnaissance de PaddleOCR (et de manga-ocr) sur les polices
# manga stylisées et le texte de petite taille. Surchargeable via
# OCR_UPSCALE (1 = désactivé).
UPSCALE_FACTOR = int(os.environ.get("OCR_UPSCALE", "2"))


def _prepare_image(image_bytes: bytes) -> Image.Image:
    """Charge l'image et l'agrandit (LANCZOS) pour améliorer la précision OCR."""
    image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
    if UPSCALE_FACTOR > 1:
        w, h = image.size
        image = image.resize(
            (w * UPSCALE_FACTOR, h * UPSCALE_FACTOR), Image.LANCZOS
        )
    return image


def _clean_ocr_text(text: str) -> str:
    """Nettoyage LÉGER du texte OCR.

    Ne corrige PAS les mots (risque de casser des noms propres ou des mots
    composés), mais retire le bruit évident : espaces multiples, espaces
    avant ponctuation, espaces en début/fin.
    """
    text = re.sub(r"\s+", " ", text)
    text = re.sub(r"\s+([,.;:!?…])", r"\1", text)
    return text.strip()


# Mapping des codes ISO 639-1 (apps/web/lib/languages.ts) vers les codes de
# langue de PaddleOCR 2.x. Les langues à alphabet latin sans modèle dédié
# (espagnol, italien, portugais, néerlandais...) utilisent le modèle
# générique `latin`. Le japonais (ja) utilise le modèle dédié `japan`.
PADDLE_LANG_MAP = {
    "ja": "japan",
    "de": "german",
    "en": "en",
    "es": "latin",
    "it": "latin",
    "pt": "latin",
    "nl": "latin",
    "fr": "french",
    "ru": "cyrillic",
    "ko": "korean",
    "zh": "ch",
}


class PaddleOcr:
    """
    OCR (PaddleOCR) pour TOUTES les langues, dont le japonais (lang='japan'),
    exécuté côté serveur. Bien plus robuste que Tesseract.js sur les polices
    manga stylisées, et déporté côté serveur — aligné avec l'objectif de mise
    à disposition publique (l'utilisateur final n'a rien à installer).

    NB : manga-ocr (plus précis sur le japonais) a été retiré car il dépend
    de transformers, incompatible avec gradio 6.24.0 imposé par HF Spaces
    ZeroGPU (conflit huggingface-hub). Le japonais passe donc par PaddleOCR.

    Les modèles PaddleOCR se téléchargent automatiquement depuis les serveurs
    de PaddleOCR au premier appel, puis restent en cache dans le conteneur.
    """

    def __init__(self) -> None:
        self._reader = None
        self._lang = None

    def _ensure_loaded(self, lang: str):
        # Un lecteur PaddleOCR est lié à une langue : on en recrée un si la
        # langue demandée change (coût unique, mis en cache ensuite).
        if self._reader is None or self._lang != lang:
            from paddleocr import PaddleOCR

            self._reader = PaddleOCR(
                use_angle_cls=True,
                lang=lang,
                show_log=False,
            )
            self._lang = lang
        return self._reader

    def read(self, image_bytes: bytes, lang: str) -> str:
        reader = self._ensure_loaded(lang)
        image = _prepare_image(image_bytes)
        result = reader.ocr(np.array(image), cls=True)

        # PaddleOCR 2.x renvoie une liste de listes : pour une seule image,
        # result[0] est la liste des détections, chacune au format
        # [box, (texte, confiance)]. On concatène les textes détectés.
        lines: list[str] = []
        if result and result[0]:
            for detection in result[0]:
                text = detection[1][0]
                if text and text.strip():
                    lines.append(_clean_ocr_text(text))
        return "\n".join(lines)