first commit

This commit is contained in:
skylanix
2026-06-28 20:21:40 +02:00
commit b66b065da1
16 changed files with 1097 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Environnements virtuels
venv/
.venv/
env/
# Caches Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Distribution / build
build/
dist/
*.egg-info/
# Sorties générées
captures/ok/
report*.html
tweets.csv
# Modèles téléchargés (EasyOCR / Hugging Face)
.EasyOCR/
.cache/
# Fichiers d'environnement / secrets
.env
# Spécifiques aux éditeurs / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
,sky,skylanix,28.06.2026 18:20,/home/sky/.local/share/onlyoffice;
+126
View File
@@ -0,0 +1,126 @@
# Classement Automatique de Captures d'Écran de Tweets par Harcèlement
Ce projet permet d'automatiser le classement de captures d'écran de tweets, en analysant leur contenu textuel pour détecter des types de harcèlement en français.
Il utilise **EasyOCR** pour extraire le texte des images et un modèle d'apprentissage profond Zero-Shot multilingue de **Hugging Face** (par défaut `MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7`) pour classer le texte extrait. Le système s'appuie sur une base de données **CSV** pour suivre l'état de traitement de chaque fichier.
---
## Fonctionnalités
- 🔎 **Extraction de texte (OCR)** : Reconnaissance optique de caractères robuste et multilingue grâce à EasyOCR.
- 🏷️ **Classification Zero-Shot** : Classification automatique de texte en catégories de harcèlement (*Cyberharcèlement*, *Insulte*, *Menace*, *Non-harcèlement*).
- 💾 **Base de données CSV** : Suivi persistant de chaque fichier (`pending`, `processed`, `error`).
- 📁 **Organisation automatique** : Déplacement (ou copie) des images dans des dossiers `ok/{catégorie}/`.
- 📊 **Rapport Web** : Génération d'un rapport HTML interactif (filtres, recherche, tri, lightbox) pour consulter les résultats.
- 🧪 **Mode simulation** : Option `--dry-run` pour prévisualiser le classement sans déplacer de fichiers.
- 💻 **CLI complète** : Contrôle total via la ligne de commande.
---
## Catégories de classification
Chaque image se voit attribuer l'une des catégories suivantes :
| Catégorie | Origine |
|---|---|
| `Cyberharcèlement`, `Insulte`, `Menace`, `Non-harcèlement` | Labels Zero-Shot retenus lorsque le score dépasse le seuil. |
| `Inclassable` | Le meilleur score est en dessous du seuil de confiance (`--threshold`). |
| `Sans_Texte` | Aucun texte n'a pu être extrait de l'image. |
| `Non-classifié` | Valeur de repli en cas de résultat inattendu. |
---
## Structure du Projet
```text
classement-image/
├── requirements.txt # Dépendances du projet
├── main.py # Point d'entrée principal
├── captures/ # Dossier d'entrée des captures à traiter
│ └── ok/ # Sortie par défaut : images classées + CSV + rapports
│ ├── tweets.csv # Base de données de suivi (générée automatiquement)
│ ├── report_*.html # Rapports visuels horodatés (--generate-report)
│ └── {catégorie}/ # Images classées par catégorie
├── tweet_classifier/ # Package principal
│ ├── ocr.py # Extraction de texte (EasyOCR)
│ ├── classifier.py # Classification Zero-Shot (Hugging Face)
│ ├── organizer.py # Orchestrateur du traitement
│ ├── database_manager.py # Gestionnaire de la base CSV
│ ├── web_generator.py # Générateur de rapport HTML (Jinja2)
│ └── cli.py # Interface en ligne de commande
└── tests/ # Suite de tests unitaires
```
---
## Installation
Installez les dépendances :
```bash
pip install -r requirements.txt
```
> ️ Au premier lancement, EasyOCR et Hugging Face téléchargent automatiquement
> leurs modèles (quelques centaines de Mo). Un GPU est utilisé s'il est
> disponible, sinon le traitement s'effectue sur CPU.
---
## Utilisation
Le script `main.py` gère le traitement des images et la génération de rapports.
### 1. Traitement des images
Pour traiter les images du dossier `captures/` :
```bash
python main.py -i ./captures
```
Les images sont classées dans des sous-dossiers de `./captures/ok/` (par défaut)
selon la catégorie détectée, et leur état est mis à jour dans
`captures/ok/tweets.csv`.
Pour choisir un autre dossier de destination :
```bash
python main.py -i ./captures -o ./resultats
```
### 2. Génération du rapport Web
Une fois le traitement terminé, générez un rapport HTML depuis la base de données :
```bash
python main.py --generate-report
```
Un fichier `report_{horodatage}.html` est créé dans le dossier de sortie
(`captures/ok/` par défaut). Ouvrez-le dans votre navigateur pour consulter la
galerie des tweets classés avec leurs images, textes et catégories détectées.
### Options de la ligne de commande
| Option | Raccourci | Description | Défaut |
|---|---|---|---|
| `--input-dir` | `-i` | Dossier contenant les captures à classer. | *Requis pour le traitement* |
| `--output-dir` | `-o` | Dossier de destination des images classées. | `{input-dir}/ok` |
| `--db` | *Aucun* | Chemin du fichier base de données CSV. | `captures/ok/tweets.csv` |
| `--generate-report` | *Aucun* | Génère un rapport HTML depuis le CSV (n'effectue pas de traitement). | `False` |
| `--model` | `-m` | Modèle Hugging Face Zero-Shot à utiliser. | `MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7` |
| `--languages` | `-l` | Langues EasyOCR, séparées par des virgules. | `fr,en` |
| `--threshold` | `-t` | Seuil de confiance de classification (0.0 à 1.0). | `0.35` |
| `--copy` | *Aucun* | Copie les images au lieu de les déplacer. | `False` |
| `--dry-run` | *Aucun* | Simulation : ne modifie ni ne déplace les fichiers. | `False` |
| `--verbose` | `-v` | Active les logs de débogage. | `False` |
---
## Exécuter les Tests
```bash
python3 -m pytest tests/
```
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""
Point d'entrée principal du script de classement automatique de captures d'écran de tweets.
"""
from tweet_classifier.cli import main
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
# Dépendances pour le classement automatique des captures de tweets
easyocr>=1.7.1
transformers>=4.38.0
torch>=2.0.0
torchvision>=0.15.0
pillow>=10.0.0
tqdm>=4.66.0
jinja2>=3.1.0
pytest>=8.0.0
+1
View File
@@ -0,0 +1 @@
# Package de tests unitaires
+8
View File
@@ -0,0 +1,8 @@
import sys
from unittest.mock import MagicMock
# Mock global pour éviter le besoin d'installer des paquets lourds en local pour les tests unitaires
sys.modules['easyocr'] = MagicMock()
sys.modules['transformers'] = MagicMock()
sys.modules['torch'] = MagicMock()
sys.modules['torchvision'] = MagicMock()
+83
View File
@@ -0,0 +1,83 @@
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
from tweet_classifier.organizer import ImageThemeOrganizer
@pytest.fixture
def temp_workspace(tmp_path):
"""
Crée une structure temporaire pour les tests.
"""
input_dir = tmp_path / "inputs"
input_dir.mkdir()
# Créer quelques fichiers d'images factices
(input_dir / "tweet1.png").write_text("fake image content 1")
(input_dir / "tweet2.jpg").write_text("fake image content 2")
(input_dir / "not_an_image.txt").write_text("some text")
return input_dir
@patch("tweet_classifier.organizer.CSVDatabaseManager")
@patch("tweet_classifier.ocr.easyocr.Reader")
@patch("tweet_classifier.classifier.pipeline")
def test_scan_new_files(mock_pipeline, mock_reader, mock_db_class, temp_workspace):
"""
Vérifie que l'organisateur scanne les fichiers et les ajoute à la base de données.
"""
mock_db = MagicMock()
mock_db_class.return_value = mock_db
organizer = ImageThemeOrganizer(input_dir=temp_workspace)
organizer.scan_new_files()
# Vérifier que add_files a été appelé avec les bonnes images
assert mock_db.add_files.called
added_files = mock_db.add_files.call_args[0][0]
assert len(added_files) == 2
assert any(img.name == "tweet1.png" for img in added_files)
assert any(img.name == "tweet2.jpg" for img in added_files)
@patch("tweet_classifier.organizer.CSVDatabaseManager")
@patch("tweet_classifier.ocr.easyocr.Reader")
@patch("tweet_classifier.classifier.pipeline")
def test_process_record_success(mock_pipeline, mock_reader, mock_db_class, temp_workspace):
"""
Vérifie le traitement réussi d'un enregistrement DB.
"""
mock_db = MagicMock()
mock_db_class.return_value = mock_db
mock_reader_instance = MagicMock()
mock_reader.return_value = mock_reader_instance
mock_reader_instance.readtext.return_value = [
(None, "Ceci est un tweet agressif", None)
]
mock_pipeline_instance = MagicMock()
mock_pipeline.return_value = mock_pipeline_instance
mock_pipeline_instance.return_value = {
"labels": ["Insulte"],
"scores": [0.9]
}
organizer = ImageThemeOrganizer(input_dir=temp_workspace)
image_path = temp_workspace / "tweet1.png"
record = {
"filename": "tweet1.png",
"filepath": str(image_path)
}
organizer._process_record(record)
# Vérifier le déplacement
dest_path = temp_workspace / "ok" / "Insulte" / "tweet1.png"
assert dest_path.exists()
# Vérifier la mise à jour DB : le nouvel emplacement doit être enregistré
mock_db.update_file_status.assert_called_with(
"tweet1.png", 'processed', "Ceci est un tweet agressif", "Insulte", 0.9,
new_filepath=str(dest_path)
)
+19
View File
@@ -0,0 +1,19 @@
import pytest
from pathlib import Path
from tweet_classifier.web_generator import WebReportGenerator
def test_web_generator_finds_csv_in_output_dir(tmp_path):
output_dir = tmp_path / "ok"
output_dir.mkdir()
# Create a CSV in output_dir
csv_file = output_dir / "my_tweets.csv"
csv_file.write_text("filepath,status,detected_category,confidence,ocr_text\n/path/to/img,processed,Cat,0.9,Text")
# Instantiate with a non-existent path but pointing to the output_dir
# The WebReportGenerator will now check in output_dir
generator = WebReportGenerator(csv_path=Path("my_tweets.csv"), output_dir=output_dir)
# This should now point to output_dir / "my_tweets.csv"
assert generator.csv_path.exists()
assert generator.csv_path == csv_file
+5
View File
@@ -0,0 +1,5 @@
from .ocr import TweetOCRExtractor
from .classifier import ZeroShotClassifier
from .organizer import ImageThemeOrganizer
__version__ = "1.0.0"
+51
View File
@@ -0,0 +1,51 @@
import logging
from typing import List, Dict, Any, Optional
from transformers import pipeline
logger = logging.getLogger(__name__)
class ZeroShotClassifier:
"""
Classe responsable de la classification de texte à l'aide de modèles Hugging Face Zero-Shot.
"""
def __init__(self, model_name: str = "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"):
"""
Initialise le classifieur zero-shot.
:param model_name: Nom du modèle Hugging Face à utiliser.
"""
self.model_name = model_name
self._pipeline = None
@property
def classifier_pipeline(self):
"""
Initialisation tardive (lazy loading) du pipeline pour économiser de la mémoire et du temps au démarrage.
"""
if self._pipeline is None:
logger.info(f"Chargement du pipeline de classification avec le modèle {self.model_name} (ceci peut prendre quelques secondes)...")
# On laisse Hugging Face gérer le choix du device (GPU s'il est dispo, sinon CPU)
self._pipeline = pipeline("zero-shot-classification", model=self.model_name)
return self._pipeline
def classify(self, text: str, candidate_labels: List[str] = None) -> Dict[str, Any]:
"""
Classifie un texte selon une liste de catégories candidates.
Si aucune catégorie n'est fournie, utilise les catégories de harcèlement par défaut.
:param text: Le texte à classifier.
:param candidate_labels: Liste des catégories (labels).
:return: Dictionnaire contenant les labels et leurs scores associés.
"""
if candidate_labels is None:
candidate_labels = ["Cyberharcèlement", "Insulte", "Menace", "Non-harcèlement"]
if not text or not text.strip():
# Si le texte est vide, on renvoie une structure vide ou par défaut
return {"labels": [], "scores": []}
try:
# On exécute le pipeline de classification
result = self.classifier_pipeline(text, candidate_labels=candidate_labels)
return result
except Exception as e:
logger.error(f"Erreur lors de la classification du texte : {e}")
raise RuntimeError(f"Échec de la classification : {e}") from e
+134
View File
@@ -0,0 +1,134 @@
import argparse
import sys
import logging
from pathlib import Path
from .organizer import ImageThemeOrganizer
from .web_generator import WebReportGenerator
def setup_logging(verbose: bool):
"""
Configure la journalisation.
"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s : %(message)s",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
def main():
parser = argparse.ArgumentParser(
description="Classement automatique de captures d'écran de tweets par thèmes à l'aide d'EasyOCR et d'Hugging Face."
)
parser.add_argument(
"-i", "--input-dir",
type=str,
default=None,
help="Chemin vers le dossier contenant les captures d'écran à classer."
)
parser.add_argument(
"-o", "--output-dir",
type=str,
default=None,
help="Dossier de destination (par défaut, utilise ./ok/)."
)
parser.add_argument(
"--db",
type=str,
default=str(Path(__file__).parent.parent / "captures/ok/tweets.csv"),
help="Chemin vers le fichier base de données CSV (défaut: 'captures/ok/tweets.csv')."
)
parser.add_argument(
"--generate-report",
action="store_true",
help="Génère un rapport HTML à partir de la base de données CSV."
)
parser.add_argument(
"-m", "--model",
type=str,
default="MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7",
help="Modèle Hugging Face Zero-Shot à utiliser."
)
parser.add_argument(
"-l", "--languages",
type=str,
default="fr,en",
help="Langues pour EasyOCR, séparées par des virgules (défaut: 'fr,en')."
)
parser.add_argument(
"-t", "--threshold",
type=float,
default=0.35,
help="Seuil de confiance de classification (0.0 à 1.0, défaut: 0.35)."
)
parser.add_argument(
"--copy",
action="store_true",
help="Copie les images au lieu de les déplacer."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Exécute une simulation sans modifier ni déplacer les fichiers."
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Active le mode verbeux pour afficher les logs de débogage."
)
args = parser.parse_args()
setup_logging(args.verbose)
if args.generate_report:
generator = WebReportGenerator(Path(args.db))
generator.generate()
return
# Normal processing path
if not args.input_dir:
print("Erreur : --input-dir est requis pour le traitement.", file=sys.stderr)
sys.exit(1)
input_path = Path(args.input_dir)
if not input_path.exists():
print(f"Erreur : Le dossier d'entrée '{args.input_dir}' n'existe pas.", file=sys.stderr)
sys.exit(1)
languages_list = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
output_path = Path(args.output_dir) if args.output_dir else None
# Instanciation de l'organisateur
organizer = ImageThemeOrganizer(
input_dir=input_path,
output_dir=output_path,
ocr_languages=languages_list,
model_name=args.model,
confidence_threshold=args.threshold,
copy_only=args.copy,
dry_run=args.dry_run,
db_path=Path(args.db)
)
try:
organizer.run()
except Exception as e:
print(f"Une erreur critique est survenue : {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
import csv
from pathlib import Path
from typing import List, Optional, Dict, Any
class CSVDatabaseManager:
def __init__(self, db_path: Path):
self.db_path = db_path
self.fieldnames = ['filename', 'filepath', 'status', 'ocr_text', 'detected_category', 'confidence', 'created_at']
self._initialize_csv()
def _initialize_csv(self):
if not self.db_path.exists():
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.db_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=self.fieldnames)
writer.writeheader()
def _read_all(self) -> List[Dict[str, Any]]:
with open(self.db_path, 'r', newline='', encoding='utf-8') as f:
return list(csv.DictReader(f))
def _write_all(self, data: List[Dict[str, Any]]):
with open(self.db_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=self.fieldnames)
writer.writeheader()
writer.writerows(data)
def add_files(self, file_paths: List[Path]):
data = self._read_all()
existing_filenames = {row['filename'] for row in data}
new_entries = []
for path in file_paths:
if path.name not in existing_filenames:
new_entries.append({
'filename': path.name,
'filepath': str(path.absolute()),
'status': 'pending',
'ocr_text': '',
'detected_category': '',
'confidence': '',
'created_at': '' # Could add timestamp here
})
if new_entries:
data.extend(new_entries)
self._write_all(data)
def get_pending_files(self) -> List[Dict[str, Any]]:
return [row for row in self._read_all() if row['status'] == 'pending']
def update_file_status(self, filename: str, status: str, ocr_text: Optional[str] = None, category: Optional[str] = None, confidence: Optional[float] = None, new_filepath: Optional[str] = None):
data = self._read_all()
for row in data:
if row['filename'] == filename:
row['status'] = status
if ocr_text is not None: row['ocr_text'] = ocr_text
if category is not None: row['detected_category'] = category
if confidence is not None: row['confidence'] = str(confidence)
if new_filepath is not None: row['filepath'] = new_filepath
break
self._write_all(data)
+50
View File
@@ -0,0 +1,50 @@
import logging
from pathlib import Path
from typing import List, Optional
import easyocr
logger = logging.getLogger(__name__)
class TweetOCRExtractor:
"""
Classe responsable de l'extraction de texte à partir de captures d'écran en utilisant EasyOCR.
"""
def __init__(self, languages: Optional[List[str]] = None):
"""
Initialise le lecteur EasyOCR.
:param languages: Liste des langues à charger (par défaut ['fr', 'en']).
"""
if languages is None:
languages = ['fr', 'en']
self.languages = languages
self._reader = None
@property
def reader(self) -> easyocr.Reader:
"""
Initialisation tardive (lazy loading) d'EasyOCR pour économiser de la mémoire si non utilisé.
"""
if self._reader is None:
logger.info("Initialisation de l'OCR EasyOCR (ceci peut prendre quelques secondes)...")
self._reader = easyocr.Reader(self.languages)
return self._reader
def extract_text(self, image_path: Path) -> str:
"""
Extrait le texte d'une image.
:param image_path: Chemin vers le fichier image.
:return: Texte brut extrait.
"""
if not image_path.exists():
raise FileNotFoundError(f"Le fichier image n'existe pas : {image_path}")
try:
# easyocr accepte un chemin de fichier sous forme de string
results = self.reader.readtext(str(image_path))
# On joint les blocs de texte détectés
text_blocks = [text for (_, text, _) in results]
extracted_text = " ".join(text_blocks).strip()
return extracted_text
except Exception as e:
logger.error(f"Erreur lors de l'extraction OCR sur {image_path.name} : {e}")
raise RuntimeError(f"Échec de l'OCR : {e}") from e
+152
View File
@@ -0,0 +1,152 @@
import logging
import shutil
from pathlib import Path
from typing import List, Optional
from tqdm import tqdm
from .ocr import TweetOCRExtractor
from .classifier import ZeroShotClassifier
from .database_manager import CSVDatabaseManager
logger = logging.getLogger(__name__)
# Extensions d'images supportées
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff'}
class ImageThemeOrganizer:
"""
Orchestrateur principal du traitement : parcourt les images, extrait le texte,
classifie, et organise les fichiers dans des sous-dossiers via une base de données.
"""
def __init__(
self,
input_dir: Path,
output_dir: Optional[Path] = None,
ocr_languages: Optional[List[str]] = None,
model_name: str = "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7",
confidence_threshold: float = 0.35,
copy_only: bool = False,
dry_run: bool = False,
db_path: Path = Path("captures/ok/tweets.csv")
):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir) if output_dir else self.input_dir / "ok"
self.confidence_threshold = confidence_threshold
self.copy_only = copy_only
self.dry_run = dry_run
self.db = CSVDatabaseManager(db_path)
# Initialisation des modules (lazy)
self.ocr_extractor = TweetOCRExtractor(languages=ocr_languages)
self.classifier = ZeroShotClassifier(model_name=model_name)
def scan_new_files(self):
"""
Trouve tous les fichiers images dans le dossier d'entrée et les ajoute à la base de données.
"""
images = []
for file in self.input_dir.iterdir():
if file.is_file() and file.suffix.lower() in SUPPORTED_EXTENSIONS:
images.append(file)
if images:
self.db.add_files(images)
logger.info(f"Ajout de {len(images)} fichiers à la base de données.")
def process_pending(self):
"""
Traite tous les fichiers en attente dans la base de données.
"""
pending_files = self.db.get_pending_files()
if not pending_files:
logger.info("Aucune image en attente.")
return
for record in tqdm(pending_files, desc="Traitement des tweets"):
self._process_record(record)
def _process_record(self, record: dict):
image_path = Path(record['filepath'])
logger.info(f"Traitement de l'image : {image_path.name}")
try:
# 1. Extraction du texte
text = self.ocr_extractor.extract_text(image_path)
dest_category = "Non-classifié"
if not text:
logger.info(f" Aucun texte extrait pour {image_path.name}.")
dest_category = "Sans_Texte"
confidence = 1.0
else:
# 2. Classification du texte (Harassment categories)
classification_result = self.classifier.classify(text)
# Récupérer la meilleure catégorie
if classification_result and classification_result.get("labels"):
best_label = classification_result["labels"][0]
best_score = classification_result["scores"][0]
logger.info(f" Classification : {best_label} (score: {best_score:.2f})")
if best_score >= self.confidence_threshold:
dest_category = best_label
confidence = best_score
else:
dest_category = "Inclassable"
confidence = best_score
else:
dest_category = "Inclassable"
confidence = 0.0
# 3. Organisation physique du fichier
dest_path = self._organize_file(image_path, dest_category)
# 4. Mise à jour DB (on enregistre le nouvel emplacement du fichier
# afin que le rapport HTML pointe vers l'image déplacée).
self.db.update_file_status(
record['filename'], 'processed', text, dest_category, confidence,
new_filepath=str(dest_path)
)
except Exception as e:
logger.error(f" Échec du traitement de {image_path.name} : {e}")
self.db.update_file_status(record['filename'], 'error')
def _organize_file(self, image_path: Path, category: str) -> Path:
"""
Crée le dossier de destination et y déplace ou copie le fichier image.
Retourne le chemin de destination du fichier (utilisé pour la base de
données et le rapport HTML).
"""
dest_dir = self.output_dir / category
dest_path = dest_dir / image_path.name
if self.dry_run:
logger.info(f"[DRY-RUN] Déplacer {image_path.name} -> {dest_dir.name}/")
return dest_path
# Créer le dossier de catégorie si nécessaire
dest_dir.mkdir(parents=True, exist_ok=True)
try:
if self.copy_only:
shutil.copy2(image_path, dest_path)
else:
shutil.move(image_path, dest_path)
logger.info(f" Organisé : {image_path.name} -> {dest_dir.name}/")
except Exception as e:
logger.error(f"Impossible d'organiser le fichier {image_path.name} vers {dest_dir} : {e}")
return dest_path
def run(self) -> dict:
"""
Exécute le processus complet.
"""
self.scan_new_files()
self.process_pending()
return {}
+353
View File
@@ -0,0 +1,353 @@
import csv
from pathlib import Path
from datetime import datetime
from urllib.parse import quote
from collections import Counter
from jinja2 import Template
# Couleurs par catégorie (avec repli sur une couleur neutre).
CATEGORY_COLORS = {
"Cyberharcèlement": "#8e44ad",
"Menace": "#c0392b",
"Insulte": "#d35400",
"Harcèlement": "#e74c3c",
"Non-harcèlement": "#27ae60",
"Sans_Texte": "#7f8c8d",
"Inclassable": "#95a5a6",
"Non-classifié": "#bdc3c7",
}
DEFAULT_COLOR = "#34495e"
TEMPLATE = """
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rapport de classification</title>
<style>
:root {
--bg: #f0f2f5;
--card-bg: #ffffff;
--text: #2c3e50;
--muted: #7f8c8d;
}
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0; padding: 0 20px 60px;
background-color: var(--bg); color: var(--text);
}
header {
position: sticky; top: 0; z-index: 50;
background: var(--bg); padding: 20px 0 12px;
border-bottom: 1px solid #dfe3e8;
}
h1 {
text-align: center; color: #1a2a6c;
margin: 0 0 4px; font-size: 1.8em;
}
.subtitle { text-align: center; color: var(--muted); margin: 0 0 16px; font-size: .9em; }
/* Barre de statistiques */
.stats {
display: flex; flex-wrap: wrap; gap: 10px;
justify-content: center; margin-bottom: 14px;
}
.stat {
background: var(--card-bg); border-radius: 10px; padding: 8px 16px;
box-shadow: 0 2px 6px rgba(0,0,0,.08); text-align: center; min-width: 90px;
}
.stat .num { font-size: 1.4em; font-weight: 700; }
.stat .lbl { font-size: .72em; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
/* Contrôles : filtres, recherche, tri */
.controls {
display: flex; flex-wrap: wrap; gap: 10px;
align-items: center; justify-content: center;
}
.filters { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.filter-btn {
border: none; cursor: pointer; color: #fff;
padding: 6px 14px; border-radius: 20px; font-size: .82em; font-weight: 600;
opacity: .55; transition: opacity .15s, transform .15s;
}
.filter-btn:hover { transform: translateY(-1px); }
.filter-btn.active { opacity: 1; box-shadow: 0 2px 8px rgba(0,0,0,.2); }
#search, #sort {
padding: 8px 12px; border: 1px solid #cfd6dd; border-radius: 8px;
font-size: .9em; background: #fff;
}
#search { min-width: 220px; }
/* Galerie */
.gallery {
display: grid; gap: 24px; margin-top: 24px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
}
.card {
background: var(--card-bg); border-radius: 15px; overflow: hidden;
box-shadow: 0 8px 18px rgba(0,0,0,.08);
transition: transform .2s, box-shadow .2s;
display: flex; flex-direction: column;
border-top: 5px solid var(--cat-color, #ccc);
}
.card:hover { transform: translateY(-5px); box-shadow: 0 14px 28px rgba(0,0,0,.14); }
.img-container {
height: 240px; overflow: hidden; background: #1c2733;
display: flex; align-items: center; justify-content: center; cursor: zoom-in;
}
.img-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; flex-grow: 1; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.category {
font-weight: 700; color: #fff; padding: 4px 12px;
border-radius: 20px; font-size: .8em; white-space: nowrap;
}
.filename { font-size: .78em; color: var(--muted); word-break: break-word; }
/* Barre de confiance */
.confidence { font-size: .78em; }
.confidence .bar-bg { background: #eceff1; border-radius: 6px; height: 8px; overflow: hidden; margin-top: 3px; }
.confidence .bar { height: 100%; border-radius: 6px; }
.conf-high { background: #27ae60; }
.conf-mid { background: #f39c12; }
.conf-low { background: #e74c3c; }
.ocr-text {
font-size: .82em; color: #444; background: #f7f9fa;
padding: 10px 12px; border-radius: 8px;
border-left: 4px solid var(--cat-color, #ccc);
max-height: 130px; overflow-y: auto; white-space: pre-wrap; line-height: 1.45;
}
.empty {
grid-column: 1 / -1; text-align: center; color: var(--muted);
padding: 60px 20px; font-size: 1.1em;
}
/* Lightbox */
#lightbox {
display: none; position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,.88); align-items: center; justify-content: center;
cursor: zoom-out; padding: 30px;
}
#lightbox img { max-width: 95%; max-height: 95%; border-radius: 8px; box-shadow: 0 0 40px rgba(0,0,0,.6); }
</style>
</head>
<body>
<header>
<h1>Rapport de classification des tweets</h1>
<p class="subtitle">Généré le {{ generated_at }} — {{ items|length }} élément(s)</p>
<div class="stats">
<div class="stat"><div class="num">{{ items|length }}</div><div class="lbl">Total</div></div>
{% for cat, count in category_counts %}
<div class="stat">
<div class="num" style="color: {{ category_colors.get(cat, default_color) }}">{{ count }}</div>
<div class="lbl">{{ cat }}</div>
</div>
{% endfor %}
</div>
<div class="controls">
<div class="filters">
<button class="filter-btn active" data-cat="all" style="background:#34495e" onclick="filterCat(this,'all')">Tout</button>
{% for cat, count in category_counts %}
<button class="filter-btn active" data-cat="{{ cat }}"
style="background: {{ category_colors.get(cat, default_color) }}"
onclick="filterCat(this,'{{ cat }}')">{{ cat }} ({{ count }})</button>
{% endfor %}
</div>
<input id="search" type="text" placeholder="🔎 Rechercher dans le texte / fichier…" oninput="applyFilters()">
<select id="sort" onchange="sortCards()">
<option value="conf-desc">Confiance ↓</option>
<option value="conf-asc">Confiance ↑</option>
<option value="cat">Catégorie (A→Z)</option>
<option value="name">Nom de fichier</option>
</select>
</div>
</header>
<div class="gallery" id="gallery">
{% for item in items %}
<div class="card" data-category="{{ item.detected_category }}"
data-confidence="{{ item.confidence_value }}"
data-filename="{{ item.filename|lower }}"
data-text="{{ item.ocr_text|lower }}"
style="--cat-color: {{ category_colors.get(item.detected_category, default_color) }}">
<div class="img-container" onclick="openLightbox('{{ item.relative_filepath }}')">
<img src="{{ item.relative_filepath }}" alt="{{ item.filename }}" loading="lazy"
onerror="this.parentElement.innerHTML='<span style=&quot;color:#bbb;font-size:.85em&quot;>Image introuvable</span>'">
</div>
<div class="card-body">
<div class="card-head">
<span class="category" style="background: {{ category_colors.get(item.detected_category, default_color) }}">
{{ item.detected_category }}
</span>
<span class="filename">{{ item.filename }}</span>
</div>
<div class="confidence">
Confiance : <strong>{{ item.confidence_pct }}%</strong>
<div class="bar-bg">
<div class="bar {{ item.conf_class }}" style="width: {{ item.confidence_pct }}%"></div>
</div>
</div>
<div class="ocr-text">{{ item.ocr_text or "— Aucun texte extrait —" }}</div>
</div>
</div>
{% else %}
<div class="empty">Aucun élément traité à afficher.<br>Lancez d'abord le traitement des images.</div>
{% endfor %}
</div>
<div id="lightbox" onclick="this.style.display='none'"><img id="lightbox-img" src="" alt=""></div>
<script>
const activeCats = new Set(['all']);
function filterCat(btn, cat) {
const buttons = document.querySelectorAll('.filter-btn');
if (cat === 'all') {
activeCats.clear(); activeCats.add('all');
buttons.forEach(b => b.classList.toggle('active', b.dataset.cat === 'all'));
} else {
document.querySelector('.filter-btn[data-cat="all"]').classList.remove('active');
activeCats.delete('all');
btn.classList.toggle('active');
if (btn.classList.contains('active')) activeCats.add(cat); else activeCats.delete(cat);
if (activeCats.size === 0) {
activeCats.add('all');
document.querySelector('.filter-btn[data-cat="all"]').classList.add('active');
}
}
applyFilters();
}
function applyFilters() {
const q = document.getElementById('search').value.toLowerCase().trim();
document.querySelectorAll('.card').forEach(card => {
const catOk = activeCats.has('all') || activeCats.has(card.dataset.category);
const txtOk = !q || card.dataset.text.includes(q) || card.dataset.filename.includes(q);
card.style.display = (catOk && txtOk) ? '' : 'none';
});
}
function sortCards() {
const mode = document.getElementById('sort').value;
const gallery = document.getElementById('gallery');
const cards = Array.from(gallery.querySelectorAll('.card'));
cards.sort((a, b) => {
const ca = parseFloat(a.dataset.confidence) || 0, cb = parseFloat(b.dataset.confidence) || 0;
switch (mode) {
case 'conf-asc': return ca - cb;
case 'conf-desc': return cb - ca;
case 'cat': return a.dataset.category.localeCompare(b.dataset.category);
case 'name': return a.dataset.filename.localeCompare(b.dataset.filename);
}
});
cards.forEach(c => gallery.appendChild(c));
}
function openLightbox(src) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox').style.display = 'flex';
}
sortCards();
</script>
</body>
</html>
"""
class WebReportGenerator:
def __init__(self, csv_path: Path, output_dir: Path = Path("captures/ok")):
self.csv_path = Path(csv_path).resolve()
self.output_dir = Path(output_dir).resolve()
# Si le CSV n'existe pas, on le cherche dans output_dir.
if not self.csv_path.exists():
potential_path = self.output_dir / self.csv_path.name
if potential_path.exists():
self.csv_path = potential_path
def _resolve_relative_path(self, row: dict) -> str:
"""
Détermine le chemin de l'image relatif au rapport HTML (placé dans
output_dir), encodé pour une URL.
Robustesse : si le chemin enregistré n'existe pas (CSV obsolète d'avant
le déplacement), on reconstruit le chemin attendu
``output_dir/catégorie/fichier``.
"""
image_path = Path(row['filepath'])
category = row.get('detected_category') or ''
candidates = [image_path]
if category:
candidates.append(self.output_dir / category / image_path.name)
candidates.append(self.output_dir / image_path.name)
chosen = next((c for c in candidates if c.exists()), image_path)
try:
relative = chosen.relative_to(self.output_dir)
except ValueError:
# Repli : catégorie/fichier, sinon juste le nom du fichier.
relative = Path(category) / image_path.name if category else Path(image_path.name)
# Encodage URL (espaces, apostrophes typographiques, accents…) en
# préservant les séparateurs de dossiers.
return quote(relative.as_posix())
@staticmethod
def _confidence_fields(raw_value: str) -> dict:
try:
value = float(raw_value)
except (TypeError, ValueError):
value = 0.0
pct = round(value * 100)
if pct >= 60:
conf_class = "conf-high"
elif pct >= 35:
conf_class = "conf-mid"
else:
conf_class = "conf-low"
return {"confidence_value": value, "confidence_pct": pct, "conf_class": conf_class}
def generate(self):
items = []
if self.csv_path.exists():
with open(self.csv_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
if row.get('status') == 'processed':
row['relative_filepath'] = self._resolve_relative_path(row)
row.update(self._confidence_fields(row.get('confidence')))
items.append(row)
# Tri par défaut : confiance décroissante.
items.sort(key=lambda r: r['confidence_value'], reverse=True)
category_counts = Counter(item['detected_category'] for item in items)
# Catégories triées par effectif décroissant.
sorted_counts = sorted(category_counts.items(), key=lambda kv: (-kv[1], kv[0]))
self.output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = self.output_dir / f"report_{timestamp}.html"
template = Template(TEMPLATE)
html = template.render(
items=items,
category_counts=sorted_counts,
category_colors=CATEGORY_COLORS,
default_color=DEFAULT_COLOR,
generated_at=datetime.now().strftime("%d/%m/%Y à %H:%M"),
)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
print(f"Rapport généré : {output_path}")
return output_path