"""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/` (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()