File size: 6,400 Bytes
ba290fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""App Gradio pour Hugging Face Spaces (option C).

Remplace le serveur FastAPI (main.py) sur HF Spaces, car le SDK Docker y est
payant (PRO) alors que le SDK Gradio est GRATUIT (CPU basic, 16 Go RAM).

Réutilise INTÉGRALEMENT la logique de inference.py et ocr.py : la détection
de bulles (RT-DETR) et l'OCR (manga-ocr / PaddleOCR) sont inchangés. Seule
la couche d'exposition change : les 4 endpoints FastAPI deviennent des
fonctions Gradio, exposées via l'API HTTP de Gradio (/gradio_api/call/...).

Le secret partagé (BUBBLE_DETECTOR_SECRET) est vérifié dans chaque fonction
via gr.Request (en-tête X-API-Key), comme le middleware de main.py.

Lancement local :
    pip install -r requirements.txt
    python app.py
    # puis tester l'API : POST /gradio_api/call/detect avec un champ "image"
"""

import os
import threading
from concurrent.futures import ThreadPoolExecutor

import gradio as gr

from inference import BubbleDetector
from ocr import PaddleOcr, PADDLE_LANG_MAP

detector = BubbleDetector()
paddle_ocr = PaddleOcr()

SECRET = os.environ.get("BUBBLE_DETECTOR_SECRET", "")

# ── ZeroGPU (Hugging Face Spaces) ──────────────────────────────────────────
# Sur le hardware ZeroGPU (option C, GRATUIT), les fonctions doivent être
# déclarées « GPU » via le décorateur `spaces.GPU` pour tourner sur la puce
# T4 partagée ; sinon elles s'exécutent sur CPU (lent, et consomment quand
# même du quota). Tout est best-effort : si le paquet `spaces` n'est pas
# installé (dev local / Docker FastAPI), les fonctions restent inchangées.
try:
    from spaces import GPU as _SPACES_GPU  # type: ignore

    def _gpu(fn):
        return _SPACES_GPU(fn)

except Exception:  # ImportError ou environnement non-HF

    def _gpu(fn):
        return fn

# Nombre de bulles OCR traitées en parallèle dans ocr_batch. HF Spaces
# gratuit n'a que 2 vCPU : 2 est le bon compromis (voir Dockerfile.hf).
try:
    _available_cpus = len(os.sched_getaffinity(0))  # Linux uniquement
except AttributeError:
    _available_cpus = os.cpu_count() or 1
OCR_BATCH_WORKERS = int(os.environ.get("OCR_BATCH_WORKERS", "2"))
_ocr_executor = ThreadPoolExecutor(
    max_workers=max(1, min(OCR_BATCH_WORKERS, _available_cpus))
)

# PaddleOCR (paddlepaddle 2.6.2) n'est PAS thread-safe pour l'inférence
# concurrente : on sérialise ses accès avec un verrou (voir main.py).
_paddle_lock = threading.Lock()


def _check_auth(request: gr.Request) -> None:
    """Refuse la requête si le secret partagé est défini et absent/incorrect."""
    if SECRET and request.headers.get("x-api-key") != SECRET:
        raise gr.Error("Clé API invalide.")


def _read_file(path: str) -> bytes:
    with open(path, "rb") as f:
        return f.read()


def _run_ocr(image_bytes: bytes, lang: str) -> str:
    """Exécute l'OCR d'UNE bulle, avec le bon moteur selon la langue."""
    try:
        if lang not in PADDLE_LANG_MAP:
            return ""
        with _paddle_lock:
            return paddle_ocr.read(image_bytes, PADDLE_LANG_MAP[lang])
    except Exception:
        return ""


# --- Fonctions exposées via l'API Gradio --------------------------------

def detect(image, request: gr.Request):
    """Détecte les bulles de texte sur une image de page de manga."""
    _check_auth(request)
    return detector.detect(_read_file(image))


def ocr_japanese(image, request: gr.Request):
    """OCR japonais (PaddleOCR lang='japan') pour une bulle croppée."""
    _check_auth(request)
    with _paddle_lock:
        return paddle_ocr.read(_read_file(image), "japan")


def ocr_general(image, lang, request: gr.Request):
    """OCR généraliste (PaddleOCR) pour une bulle croppée."""
    _check_auth(request)
    if lang not in PADDLE_LANG_MAP:
        raise gr.Error(f"Langue non supportée par l'OCR généraliste : {lang}")
    return paddle_ocr.read(_read_file(image), PADDLE_LANG_MAP[lang])


def ocr_batch(images, lang, request: gr.Request):
    """OCR de TOUTES les bulles d'une page en UN seul appel.

    `images` est une liste de chemins de fichiers (gr.File file_count="multiple").
    Renvoie une liste de textes dans le MÊME ORDRE que les images reçues.
    """
    _check_auth(request)
    payloads = [_read_file(p) for p in images]
    texts = list(_ocr_executor.map(lambda p: _run_ocr(p, lang), payloads))
    return texts


detect = _gpu(detect)
ocr_japanese = _gpu(ocr_japanese)
ocr_general = _gpu(ocr_general)
ocr_batch = _gpu(ocr_batch)


# --- Interface Gradio (UI minimale + API) -------------------------------

with gr.Blocks(title="Manga OCR") as demo:
    gr.Markdown(
        "## Manga Reader — détection de bulles + OCR\n"
        "Service interne appelé par l'app web. Utilisez l'API "
        "`/gradio_api/call/<nom>` (voir le guide)."
    )

    # Détection de bulles
    # Gradio >= 6.0 (HF Spaces ZeroGPU impose gradio==6.24.0) : gr.File renvoie
    # un chemin de fichier (str) par défaut ; les paramètres type= et filepath=
    # ont été supprimés. La fonction reçoit donc directement le chemin.
    detect_in = gr.File(label="Page image")
    detect_out = gr.JSON(label="Bulles")
    detect_btn = gr.Button("Détecter les bulles")
    detect_btn.click(
        fn=detect, inputs=detect_in, outputs=detect_out, api_name="detect"
    )

    # OCR japonais
    ja_in = gr.File(label="Bulle (japonais)")
    ja_out = gr.Textbox(label="Texte")
    ja_btn = gr.Button("OCR japonais")
    ja_btn.click(
        fn=ocr_japanese, inputs=ja_in, outputs=ja_out, api_name="ocr_japanese"
    )

    # OCR généraliste
    gen_img = gr.File(label="Bulle (généraliste)")
    gen_lang = gr.Textbox(value="en", label="lang (ISO 639-1)")
    gen_out = gr.Textbox(label="Texte")
    gen_btn = gr.Button("OCR généraliste")
    gen_btn.click(
        fn=ocr_general,
        inputs=[gen_img, gen_lang],
        outputs=gen_out,
        api_name="ocr_general",
    )

    # OCR par lot
    batch_imgs = gr.File(file_count="multiple", label="Bulles (plusieurs)")
    batch_lang = gr.Textbox(value="ja", label="lang (ISO 639-1)")
    batch_out = gr.JSON(label="Textes")
    batch_btn = gr.Button("OCR par lot")
    batch_btn.click(
        fn=ocr_batch,
        inputs=[batch_imgs, batch_lang],
        outputs=batch_out,
        api_name="ocr_batch",
    )

demo.queue()
demo.launch()