bubble-detector / app.py
VaiiZ's picture
Upload app.py
ba290fd verified
Raw History Blame Contribute Delete
6.4 kB
"""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()