- Move all Python scripts to tools/ directory - Move documentation files to docs/ directory - Create exams/ and homework/ directories for future use - Remove temporary test file (page1_preview.png) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
434 lines
14 KiB
Python
434 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Pipeline OCR pour extraire le texte des manuels de chinois (PDF scannés)
|
|
Utilise PaddleOCR pour la reconnaissance de caractères chinois
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
import json
|
|
from datetime import datetime
|
|
from dotenv import load_dotenv
|
|
|
|
try:
|
|
from paddleocr import PaddleOCR
|
|
from pdf2image import convert_from_path
|
|
from PIL import Image
|
|
from openai import OpenAI
|
|
except ImportError as e:
|
|
print(f"Erreur d'import: {e}")
|
|
print("\nInstallez les dépendances avec:")
|
|
print("pip install paddlepaddle paddleocr pdf2image Pillow openai python-dotenv")
|
|
print("\nSur Linux, installez aussi: sudo apt-get install poppler-utils")
|
|
sys.exit(1)
|
|
|
|
# Charger les variables d'environnement
|
|
load_dotenv()
|
|
|
|
|
|
class ChineseOCRPipeline:
|
|
"""Pipeline OCR pour traiter les PDFs de manuels chinois"""
|
|
|
|
def __init__(self, lang: str = 'ch', use_gpu: bool = False, use_ai_correction: bool = False):
|
|
"""
|
|
Initialise le pipeline OCR
|
|
|
|
Args:
|
|
lang: Langue pour l'OCR ('ch' pour chinois, 'en' pour anglais)
|
|
use_gpu: Utiliser le GPU si disponible
|
|
use_ai_correction: Activer la correction IA avec GPT-4o-mini
|
|
"""
|
|
print(f"Initialisation de PaddleOCR (langue: {lang})...")
|
|
self.ocr = PaddleOCR(
|
|
use_textline_orientation=True, # Détection d'angle pour images tournées
|
|
lang=lang
|
|
)
|
|
print("✓ PaddleOCR initialisé")
|
|
|
|
# Initialiser OpenAI si correction activée
|
|
self.use_ai_correction = use_ai_correction
|
|
self.openai_client = None
|
|
if use_ai_correction:
|
|
api_key = os.getenv('OPENAI_API_KEY')
|
|
if not api_key:
|
|
print("⚠️ OPENAI_API_KEY non trouvée dans .env - correction IA désactivée")
|
|
self.use_ai_correction = False
|
|
else:
|
|
self.openai_client = OpenAI(api_key=api_key)
|
|
print("✓ Correction IA (GPT-4o-mini) activée")
|
|
|
|
def pdf_to_images(self, pdf_path: Path, dpi: int = 300) -> List[Image.Image]:
|
|
"""
|
|
Convertit un PDF en images
|
|
|
|
Args:
|
|
pdf_path: Chemin vers le PDF
|
|
dpi: Résolution de conversion (300 recommandé pour l'OCR)
|
|
|
|
Returns:
|
|
Liste d'images PIL
|
|
"""
|
|
print(f" Conversion du PDF en images (DPI: {dpi})...")
|
|
try:
|
|
images = convert_from_path(str(pdf_path), dpi=dpi)
|
|
print(f" ✓ {len(images)} pages converties")
|
|
return images
|
|
except Exception as e:
|
|
print(f" ✗ Erreur lors de la conversion: {e}")
|
|
return []
|
|
|
|
def extract_text_from_image(self, image: Image.Image) -> Tuple[str, List[dict]]:
|
|
"""
|
|
Extrait le texte d'une image avec PaddleOCR
|
|
|
|
Args:
|
|
image: Image PIL
|
|
|
|
Returns:
|
|
Tuple (texte_brut, résultats_détaillés)
|
|
"""
|
|
import numpy as np
|
|
|
|
# Convertir PIL Image en numpy array
|
|
img_array = np.array(image)
|
|
|
|
# Lancer l'OCR (utilise la nouvelle API predict)
|
|
result = self.ocr.predict(img_array)
|
|
|
|
# Extraire le texte
|
|
# Le format de retour est une liste contenant un objet OCRResult
|
|
if result is None or not isinstance(result, list) or len(result) == 0:
|
|
return "", []
|
|
|
|
# Prendre le premier résultat (un seul résultat par image)
|
|
ocr_result = result[0]
|
|
|
|
# Accéder aux attributs comme un dictionnaire
|
|
rec_texts = ocr_result.get('rec_texts', [])
|
|
rec_scores = ocr_result.get('rec_scores', [])
|
|
rec_polys = ocr_result.get('rec_polys', [])
|
|
|
|
if not rec_texts:
|
|
return "", []
|
|
|
|
text_lines = []
|
|
detailed_results = []
|
|
|
|
# Itérer sur les textes, scores et polygones
|
|
for i, text in enumerate(rec_texts):
|
|
confidence = rec_scores[i] if i < len(rec_scores) else 0.0
|
|
bbox = rec_polys[i].tolist() if i < len(rec_polys) else []
|
|
|
|
text_lines.append(text)
|
|
detailed_results.append({
|
|
'text': text,
|
|
'confidence': float(confidence),
|
|
'bbox': bbox
|
|
})
|
|
|
|
return '\n'.join(text_lines), detailed_results
|
|
|
|
def correct_text_with_ai(self, text: str, page_num: int) -> str:
|
|
"""
|
|
Corrige le texte OCR avec GPT-4o-mini
|
|
|
|
Args:
|
|
text: Texte brut de l'OCR
|
|
page_num: Numéro de la page
|
|
|
|
Returns:
|
|
Texte corrigé
|
|
"""
|
|
if not self.use_ai_correction or not self.openai_client:
|
|
return text
|
|
|
|
if not text.strip():
|
|
return text
|
|
|
|
try:
|
|
response = self.openai_client.chat.completions.create(
|
|
model="gpt-4o-mini",
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": """Tu es un expert en chinois qui corrige les erreurs OCR dans des manuels de chinois.
|
|
|
|
Ton travail:
|
|
1. Corriger les erreurs OCR évidentes (caractères manquants, mal reconnus)
|
|
2. Vérifier la cohérence pinyin/caractères chinois
|
|
3. Corriger la ponctuation chinoise si nécessaire
|
|
4. Si une phrase semble incomplète (ex: "心里有说不出的兴" au lieu de "心里有说不出的高兴"), la compléter
|
|
5. Corriger les questions qui citent le texte principal
|
|
|
|
IMPORTANT:
|
|
- Ne PAS modifier la structure ou l'ordre du texte
|
|
- Ne PAS ajouter de contenu qui n'était pas là
|
|
- Ne PAS traduire
|
|
- Garder EXACTEMENT le même formatage (sauts de ligne, espaces)
|
|
- Seulement corriger les erreurs évidentes"""
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": f"Corrige les erreurs OCR dans ce texte de la page {page_num} d'un manuel de chinois:\n\n{text}"
|
|
}
|
|
],
|
|
temperature=0.1,
|
|
max_tokens=4000
|
|
)
|
|
|
|
corrected_text = response.choices[0].message.content
|
|
return corrected_text
|
|
|
|
except Exception as e:
|
|
print(f" ⚠️ Erreur correction IA: {e}")
|
|
return text
|
|
|
|
def process_pdf(self, pdf_path: Path, output_dir: Path) -> bool:
|
|
"""
|
|
Traite un fichier PDF et sauvegarde les résultats
|
|
|
|
Args:
|
|
pdf_path: Chemin vers le PDF
|
|
output_dir: Dossier de sortie
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
print(f"\n{'='*60}")
|
|
print(f"Traitement: {pdf_path.name}")
|
|
print(f"{'='*60}")
|
|
|
|
# Convertir PDF en images
|
|
images = self.pdf_to_images(pdf_path)
|
|
if not images:
|
|
return False
|
|
|
|
# Créer le dossier de sortie
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Nom de base pour les fichiers de sortie
|
|
base_name = pdf_path.stem
|
|
|
|
# Traiter chaque page
|
|
all_text = []
|
|
all_text_corrected = []
|
|
all_detailed_results = []
|
|
|
|
for page_num, image in enumerate(images, start=1):
|
|
print(f"\n {'─'*50}")
|
|
print(f" 📄 Page {page_num}/{len(images)}")
|
|
print(f" {'─'*50}")
|
|
|
|
text, detailed = self.extract_text_from_image(image)
|
|
|
|
# Afficher un aperçu du texte extrait
|
|
if text:
|
|
lines = text.split('\n')
|
|
num_lines = len(lines)
|
|
preview = '\n '.join(lines[:5]) # Afficher les 5 premières lignes
|
|
print(f" ✓ {num_lines} ligne(s) détectée(s)")
|
|
print(f" 📝 Aperçu OCR brut:")
|
|
print(f" {preview}")
|
|
if num_lines > 5:
|
|
print(f" ... ({num_lines - 5} ligne(s) supplémentaire(s))")
|
|
else:
|
|
print(f" ⚠️ Aucun texte détecté sur cette page")
|
|
|
|
# Correction IA si activée
|
|
corrected_text = text
|
|
if self.use_ai_correction and text:
|
|
print(f" 🤖 Correction IA en cours...")
|
|
corrected_text = self.correct_text_with_ai(text, page_num)
|
|
print(f" ✓ Correction terminée")
|
|
|
|
all_text.append(f"=== Page {page_num} ===\n{text}\n")
|
|
all_text_corrected.append(f"=== Page {page_num} ===\n{corrected_text}\n")
|
|
all_detailed_results.append({
|
|
'page': page_num,
|
|
'text': text,
|
|
'text_corrected': corrected_text if self.use_ai_correction else None,
|
|
'details': detailed
|
|
})
|
|
|
|
# Sauvegarder le texte brut
|
|
txt_output = output_dir / f"{base_name}.txt"
|
|
with open(txt_output, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(all_text))
|
|
print(f" ✓ Texte brut sauvegardé: {txt_output}")
|
|
|
|
# Sauvegarder le texte corrigé si correction IA activée
|
|
if self.use_ai_correction:
|
|
txt_corrected_output = output_dir / f"{base_name}_corrected.txt"
|
|
with open(txt_corrected_output, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(all_text_corrected))
|
|
print(f" ✓ Texte corrigé sauvegardé: {txt_corrected_output}")
|
|
|
|
# Sauvegarder les résultats détaillés (JSON)
|
|
json_output = output_dir / f"{base_name}.json"
|
|
with open(json_output, 'w', encoding='utf-8') as f:
|
|
json.dump({
|
|
'source_pdf': str(pdf_path),
|
|
'processed_at': datetime.now().isoformat(),
|
|
'ai_correction_enabled': self.use_ai_correction,
|
|
'pages': all_detailed_results
|
|
}, f, ensure_ascii=False, indent=2)
|
|
print(f" ✓ Détails sauvegardés: {json_output}")
|
|
|
|
return True
|
|
|
|
def process_directory(self, base_dir: Path = Path("Raw")) -> dict:
|
|
"""
|
|
Traite tous les PDFs dans Raw/*/PDF/
|
|
|
|
Args:
|
|
base_dir: Dossier de base (défaut: Raw)
|
|
|
|
Returns:
|
|
Dictionnaire avec statistiques de traitement
|
|
"""
|
|
stats = {
|
|
'total': 0,
|
|
'success': 0,
|
|
'failed': 0,
|
|
'files': []
|
|
}
|
|
|
|
# Trouver tous les dossiers PDF
|
|
pdf_dirs = list(base_dir.glob("*/PDF"))
|
|
|
|
print(f"\nTrouvé {len(pdf_dirs)} dossiers de PDFs")
|
|
|
|
for pdf_dir in pdf_dirs:
|
|
# Dossier de sortie (même niveau que PDF)
|
|
output_dir = pdf_dir.parent / "OCR"
|
|
|
|
# Trouver tous les PDFs
|
|
pdf_files = list(pdf_dir.glob("*.pdf")) + list(pdf_dir.glob("*.PDF"))
|
|
|
|
print(f"\n{pdf_dir.relative_to(base_dir)}: {len(pdf_files)} fichiers")
|
|
|
|
for pdf_file in pdf_files:
|
|
stats['total'] += 1
|
|
success = self.process_pdf(pdf_file, output_dir)
|
|
|
|
if success:
|
|
stats['success'] += 1
|
|
stats['files'].append(str(pdf_file.relative_to(base_dir)))
|
|
else:
|
|
stats['failed'] += 1
|
|
|
|
return stats
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Pipeline OCR pour manuels de chinois",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Exemples:
|
|
# Mode auto - traiter tous les PDFs
|
|
python ocr_pipeline.py --mode auto
|
|
|
|
# Mode manuel - traiter un fichier spécifique
|
|
python ocr_pipeline.py --mode manual --input "Raw/DevelopChinese/PDF/听力Chapter1.pdf"
|
|
|
|
# Avec GPU
|
|
python ocr_pipeline.py --mode auto --gpu
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--mode',
|
|
choices=['auto', 'manual'],
|
|
default='auto',
|
|
help='Mode de traitement (auto: tous les PDFs, manual: un fichier)'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--input',
|
|
type=str,
|
|
help='Chemin du fichier PDF (requis en mode manual)'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--output',
|
|
type=str,
|
|
help='Dossier de sortie personnalisé (optionnel en mode manual)'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--gpu',
|
|
action='store_true',
|
|
help='Utiliser le GPU si disponible'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--dpi',
|
|
type=int,
|
|
default=300,
|
|
help='Résolution pour la conversion PDF (défaut: 300)'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--ai-correction',
|
|
action='store_true',
|
|
help='Activer la correction IA avec GPT-4o-mini (nécessite OPENAI_API_KEY dans .env)'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validation
|
|
if args.mode == 'manual' and not args.input:
|
|
parser.error("--input est requis en mode manual")
|
|
|
|
# Initialiser le pipeline
|
|
pipeline = ChineseOCRPipeline(
|
|
lang='ch',
|
|
use_gpu=args.gpu,
|
|
use_ai_correction=args.ai_correction
|
|
)
|
|
|
|
# Mode auto
|
|
if args.mode == 'auto':
|
|
print("\n🚀 Mode AUTO - Traitement de tous les PDFs")
|
|
stats = pipeline.process_directory()
|
|
|
|
print(f"\n{'='*60}")
|
|
print("RÉSUMÉ")
|
|
print(f"{'='*60}")
|
|
print(f"Total: {stats['total']} fichiers")
|
|
print(f"Succès: {stats['success']} fichiers")
|
|
print(f"Échecs: {stats['failed']} fichiers")
|
|
print(f"{'='*60}")
|
|
|
|
# Mode manuel
|
|
else:
|
|
print(f"\n🎯 Mode MANUAL - Traitement de: {args.input}")
|
|
|
|
pdf_path = Path(args.input)
|
|
if not pdf_path.exists():
|
|
print(f"Erreur: Le fichier {args.input} n'existe pas")
|
|
sys.exit(1)
|
|
|
|
# Déterminer le dossier de sortie
|
|
if args.output:
|
|
output_dir = Path(args.output)
|
|
else:
|
|
# Par défaut: Raw/*/OCR/
|
|
output_dir = pdf_path.parent.parent / "OCR"
|
|
|
|
success = pipeline.process_pdf(pdf_path, output_dir)
|
|
|
|
if success:
|
|
print("\n✓ Traitement réussi!")
|
|
else:
|
|
print("\n✗ Échec du traitement")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|