first commit
This commit is contained in:
@@ -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="color:#bbb;font-size:.85em">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
|
||||
Reference in New Issue
Block a user