LifePlay-Revival/Tools/anim/RapportControles.py
2026-10-07 15:49:56 +02:00

359 lines
16 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Genere un rapport HTML statique a partir des sorties du commandlet
d'evaluation d'animations (rapport.json, rapport_poseT_*.json, eval_*.csv,
controles_*.csv).
Usage :
python RapportControles.py [dossier_resultats]
Par defaut : D:\\Game\\LifePlay-master 5.5\\Saved\\AnimPipeline\\quaternius
Sortie : <dossier>/rapport.html (une seule page, CSS integre, hors ligne).
Bibliotheque standard uniquement.
"""
import csv
import datetime
import html
import json
import os
import sys
# Defaut = dossier du pack Quaternius ; tout autre pack : passer son dossier en argument.
DOSSIER_DEFAUT = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"Saved", "AnimPipeline", "quaternius")
# (cle CSV valeur, cle CSV os, titre, unite, seuil)
MESURES = [
("sol_cm", "sol_os", "Sol", "cm", 3.0),
("glissement_cm", "glissement_os", "Glissement", "cm/image", 3.0),
("genou_cm", "genou_os", "Genou", "cm", 1.0),
("flexion_deg", "flexion_os", "Flexion", "\u00b0", 5.0),
("saut_deg", "saut_os", "Saut", "\u00b0", 10.0),
("penetration_cm", "penetration_os", "Penetration", "cm", 1.0),
]
FACTEUR_REJET = 3.0
ORDRE_VERDICT = {"rejete": 0, "a revoir": 1, "accepte": 2}
def esc(texte):
return html.escape(str(texte), quote=True)
def lire_json(chemin):
# utf-8-sig : tolere un eventuel BOM
with open(chemin, "r", encoding="utf-8-sig") as f:
return json.load(f)
def lire_csv(chemin):
with open(chemin, "r", encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def nombre(valeur, defaut=0.0):
try:
return float(valeur)
except (TypeError, ValueError):
return defaut
def fmt(valeur, decimales=2):
"""Formate un nombre ; notation scientifique pour les tres petites valeurs."""
try:
x = float(valeur)
except (TypeError, ValueError):
return "-"
if x != 0 and abs(x) < 0.005:
return "%.1e" % x
return ("%." + str(decimales) + "f") % x
def classe_mesure(valeur, seuil):
if valeur > seuil * FACTEUR_REJET:
return "m-rejet"
if valeur > seuil:
return "m-revoir"
return ""
# ---------------------------------------------------------------- sections
def html_entete(rapport, date_txt):
valide = bool(rapport.get("valide"))
cls = "ok" if valide else "ko"
texte = "VALIDE" if valide else "REFUS\u00c9"
return (
'<header>\n'
'<h1>Rapport des contr\u00f4les d\'animations</h1>\n'
'<div class="verdict %s" role="status">%s</div>\n'
'<dl class="meta">\n'
'<div><dt>G\u00e9n\u00e9r\u00e9 le</dt><dd>%s</dd></div>\n'
'<div><dt>Dossier des clips</dt><dd><code>%s</code></dd></div>\n'
'<div><dt>Clips</dt><dd>%s</dd></div>\n'
'<div><dt>Instants par clip</dt><dd>%s</dd></div>\n'
'</dl>\n</header>\n'
) % (cls, texte, esc(date_txt), esc(rapport.get("dossier_clips", "?")),
esc(rapport.get("clips", "?")), esc(rapport.get("instants", "?")))
def html_echecs_revoir(rapport):
echecs = rapport.get("echecs") or []
revoir = rapport.get("a_revoir") or []
sortie = ['<section>\n<h2>\u00c9checs</h2>\n']
if echecs:
sortie.append('<ul class="liste ko-liste">\n')
sortie += ["<li>%s</li>\n" % esc(e) for e in echecs]
sortie.append("</ul>\n")
else:
sortie.append('<p class="vide">Aucun \u00e9chec.</p>\n')
sortie.append("</section>\n<section>\n<h2>Clips \u00e0 revoir</h2>\n")
if revoir:
sortie.append('<ul class="liste">\n')
sortie += ["<li>%s</li>\n" % esc(e) for e in revoir]
sortie.append("</ul>\n")
else:
sortie.append('<p class="vide">Aucun clip \u00e0 revoir.</p>\n')
sortie.append("</section>\n")
return "".join(sortie)
def html_legende():
items = []
for _, _, titre, unite, seuil in MESURES:
items.append("<li><b>%s</b> %s %s</li>" % (
esc(titre), esc(fmt(seuil, 0)), esc(unite)))
return (
'<section class="legende">\n<h2>L\u00e9gende des seuils</h2>\n'
'<ul class="seuils">%s</ul>\n'
'<p>Une mesure au-del\u00e0 du seuil donne <span class="puce revoir">'
'\u00e0 revoir</span> ; au-del\u00e0 de %s fois le seuil, '
'<span class="puce rejete">rejet\u00e9</span>. '
'Dans chaque cellule : la valeur, puis l\'os concern\u00e9 en petit.</p>\n'
'</section>\n'
) % ("".join(items), esc(fmt(FACTEUR_REJET, 0)))
def html_resume_corps(c):
cartes = [
("Formule pire", fmt(c.get("formule_pire"), 3) + " %"),
("Clip pire", fmt(c.get("clip_pire"), 3) + " %"),
("Torsion pire", fmt(c.get("torsion_pire_deg"), 3) + " \u00b0"),
("Clips \u00e0 revoir", str(c.get("clips_a_revoir_controles", "?"))),
("Instants mesur\u00e9s", str(c.get("instants_mesures", "?"))),
("Instants hors seuil", str(c.get("instants_hors_seuil", "?"))),
]
return '<div class="cartes">%s</div>\n' % "".join(
'<div class="carte"><span class="k">%s</span><span class="v">%s</span></div>'
% (esc(k), esc(v)) for k, v in cartes)
def html_controles(lignes):
lignes = sorted(
lignes, key=lambda r: (ORDRE_VERDICT.get(r.get("verdict", ""), 3),
r.get("clip", "")))
tete = "<th>Clip</th><th>Images</th>" + "".join(
"<th>%s <small>(%s)</small></th>" % (esc(t), esc(u))
for _, _, t, u, _ in MESURES) + "<th>Verdict</th>"
corps = []
for r in lignes:
verdict = r.get("verdict", "")
cls = {"rejete": "v-rejete", "a revoir": "v-revoir"}.get(verdict, "v-accepte")
cases = ['<td class="clip">%s</td>' % esc(r.get("clip", "")),
'<td class="num">%s</td>' % esc(r.get("images", ""))]
for cle_val, cle_os, _, _, seuil in MESURES:
v = nombre(r.get(cle_val))
os_ = r.get(cle_os, "")
cases.append(
'<td class="num %s">%s%s</td>' % (
classe_mesure(v, seuil), esc(fmt(v)),
('<small class="os">%s</small>' % esc(os_)) if os_ else ""))
cases.append('<td><span class="puce %s">%s</span></td>' % (
esc(verdict.replace(" ", "")), esc(verdict)))
corps.append('<tr class="%s">%s</tr>' % (cls, "".join(cases)))
return ('<div class="defile"><table class="controles">\n<thead><tr>%s</tr></thead>\n'
'<tbody>\n%s\n</tbody></table></div>\n') % (tete, "\n".join(corps))
def html_pose(pose):
if not pose:
return '<p class="vide">Pas de rapport de pose T pour ce corps.</p>\n'
seg = pose.get("segments") or []
lignes = []
for s in seg:
cls = "ajuste" if s.get("ajuste") else ""
lignes.append(
'<tr class="%s"><td>%s</td><td class="num">%s</td><td class="num">%s</td>'
'<td>%s</td></tr>' % (
cls, esc(s.get("segment", "")), esc(fmt(s.get("avant"))),
esc(fmt(s.get("apres"))), "ajust\u00e9" if s.get("ajuste") else ""))
reglages = pose.get("reglages") or []
lig_reg = ['<tr><td>%s</td><td class="num">%s</td></tr>' % (
esc(r.get("os", "")), esc(fmt(r.get("degres")))) for r in reglages]
valide = pose.get("valide")
return (
'<p>Profil : <b>%s</b> \u00b7 sym\u00e9trie max : <b>%s</b> \u00b7 pose T : '
'<span class="puce %s">%s</span></p>\n'
'<div class="deux">\n'
'<div><h4>Segments (\u00e9cart en %% de 180\u00b0, avant / apr\u00e8s ajustement)</h4>'
'<div class="defile"><table><thead><tr><th>Segment</th><th>Avant</th>'
'<th>Apr\u00e8s</th><th></th></tr></thead><tbody>%s</tbody></table></div></div>\n'
'<div><h4>R\u00e9glages appliqu\u00e9s (degr\u00e9s)</h4>'
'<div class="defile"><table><thead><tr><th>Os</th><th>Degr\u00e9s</th></tr>'
'</thead><tbody>%s</tbody></table></div></div>\n</div>\n'
) % (esc(pose.get("profil", "?")), esc(fmt(pose.get("ecart_symetrie_max"), 4)),
"accepte" if valide else "rejete", "valide" if valide else "refus\u00e9",
"".join(lignes), "".join(lig_reg) or
'<tr><td colspan="2">Aucun r\u00e9glage.</td></tr>')
def html_ecarts(lignes):
tete = ("<th>Clip</th><th>Instant (s)</th><th>Formule (%)</th><th>Clip brut (%)</th>"
"<th>Torsion (\u00b0)</th>")
corps = []
for r in lignes:
corps.append(
'<tr><td class="clip">%s</td><td class="num">%s</td>'
'<td class="num">%s<small class="os">%s</small></td>'
'<td class="num">%s<small class="os">%s</small></td>'
'<td class="num">%s<small class="os">%s</small></td></tr>' % (
esc(r.get("clip", "")), esc(r.get("instant", "")),
esc(fmt(r.get("formule_pire"))), esc(r.get("formule_os", "")),
esc(fmt(r.get("brut_pire"))), esc(r.get("brut_os", "")),
esc(fmt(r.get("torsion_deg"))), esc(r.get("torsion_os", ""))))
return (
'<details><summary>\u00c9carts formule / clip / torsion par clip et par '
'instant (%d lignes)</summary>\n<div class="defile"><table class="ecarts">'
'<thead><tr>%s</tr></thead><tbody>\n%s\n</tbody></table></div></details>\n'
) % (len(lignes), tete, "\n".join(corps))
CSS = """
:root{--fond:#f6f7f9;--carte:#fff;--texte:#1b1f24;--doux:#5b6470;--bord:#d7dce2;
--ok:#1a7f37;--ok-fond:#dafbe1;--ko:#cf222e;--ko-fond:#ffebe9;
--revoir:#9a6700;--revoir-fond:#fff3c4;--rejet-fond:#ffd8d3;--entete:#eef0f3}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--fond:#0f1317;
--carte:#171c22;--texte:#e6e9ed;--doux:#98a2b0;--bord:#2d353f;--ok:#56d364;
--ok-fond:#12351d;--ko:#ff7b72;--ko-fond:#3d1518;--revoir:#e3b341;
--revoir-fond:#3a2d0b;--rejet-fond:#4a1a1a;--entete:#1f262e}}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--fond);color:var(--texte);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:1100px;margin:0 auto;padding:16px}
h1{font-size:1.5rem;margin:.2em 0 .5em}
h2{font-size:1.25rem;margin:1.6em 0 .5em;border-bottom:1px solid var(--bord);padding-bottom:.2em}
h3{font-size:1.1rem;margin:1.2em 0 .4em}
h4{font-size:.95rem;margin:.8em 0 .3em;color:var(--doux)}
code{font-family:ui-monospace,Consolas,monospace;font-size:.9em;overflow-wrap:anywhere}
.verdict{display:inline-block;font-size:2rem;font-weight:800;letter-spacing:.06em;
padding:.2em .7em;border-radius:10px;border:3px solid}
.verdict.ok{color:var(--ok);background:var(--ok-fond);border-color:var(--ok)}
.verdict.ko{color:var(--ko);background:var(--ko-fond);border-color:var(--ko)}
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px 16px;margin:16px 0 0}
.meta div{background:var(--carte);border:1px solid var(--bord);border-radius:8px;padding:8px 12px;min-width:0}
.meta dt{font-size:.8rem;color:var(--doux)}
.meta dd{margin:0;font-weight:600;overflow-wrap:anywhere}
.liste{margin:.3em 0;padding-left:1.3em}
.liste li{overflow-wrap:anywhere}
.ko-liste li{color:var(--ko)}
.vide{color:var(--doux);font-style:italic}
.seuils{display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;padding:0;margin:.3em 0}
.seuils li{background:var(--carte);border:1px solid var(--bord);border-radius:6px;padding:2px 10px}
.cartes{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:.6em 0}
.carte{background:var(--carte);border:1px solid var(--bord);border-radius:8px;padding:8px 12px;
display:flex;flex-direction:column}
.carte .k{font-size:.8rem;color:var(--doux)}
.carte .v{font-size:1.2rem;font-weight:700}
.defile{overflow-x:auto;max-width:100%;border:1px solid var(--bord);border-radius:8px;background:var(--carte)}
table{border-collapse:collapse;width:100%;font-size:.88rem}
th,td{padding:4px 8px;border-bottom:1px solid var(--bord);text-align:left;white-space:nowrap}
th{background:var(--entete);position:sticky;top:0}
th small{font-weight:400;color:var(--doux)}
td.num{text-align:right;font-variant-numeric:tabular-nums}
td.clip{font-weight:600}
small.os{display:block;font-size:.7rem;color:var(--doux);line-height:1.1}
tr.v-rejete td{background:var(--rejet-fond)}
tr.v-revoir td{background:var(--revoir-fond)}
td.m-revoir{color:var(--revoir);font-weight:700}
td.m-rejet{color:var(--ko);font-weight:800}
tr.ajuste td{background:var(--revoir-fond)}
.puce{display:inline-block;padding:0 .6em;border-radius:999px;font-size:.8rem;font-weight:700;
border:1px solid var(--bord)}
.puce.accepte{color:var(--ok);background:var(--ok-fond);border-color:var(--ok)}
.puce.arevoir,.puce.revoir{color:var(--revoir);background:var(--revoir-fond);border-color:var(--revoir)}
.puce.rejete{color:var(--ko);background:var(--ko-fond);border-color:var(--ko)}
.deux{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
.deux>div{min-width:0}
details{margin:.8em 0}
summary{cursor:pointer;font-weight:600;padding:4px 0}
.ecarts th{position:static}
footer{margin:2em 0 1em;color:var(--doux);font-size:.8rem}
"""
def construire(dossier):
rapport = lire_json(os.path.join(dossier, "rapport.json"))
date_txt = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S")
parts = ["<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<meta name=\"color-scheme\" content=\"light dark\">\n"
"<title>Rapport des contr\u00f4les d'animations</title>\n"
"<style>%s</style>\n</head>\n<body>\n<main>\n" % CSS]
parts.append(html_entete(rapport, date_txt))
parts.append(html_echecs_revoir(rapport))
parts.append(html_legende())
stats = {}
for c in rapport.get("corps", []):
nom = c.get("corps", "?")
chemin_ctrl = os.path.join(dossier, "controles_%s.csv" % nom)
chemin_eval = os.path.join(dossier, "eval_%s.csv" % nom)
chemin_pose = os.path.join(dossier, "rapport_poseT_%s.json" % nom)
ctrl = lire_csv(chemin_ctrl) if os.path.isfile(chemin_ctrl) else []
evals = lire_csv(chemin_eval) if os.path.isfile(chemin_eval) else []
pose = lire_json(chemin_pose) if os.path.isfile(chemin_pose) else None
stats[nom] = (len(ctrl), len(evals))
parts.append('<section class="corps" id="corps-%s">\n<h2>Corps : %s</h2>\n'
% (esc(nom), esc(nom)))
parts.append(html_resume_corps(c))
parts.append("<h3>Contr\u00f4les par clip</h3>\n")
if ctrl:
parts.append(html_controles(ctrl))
else:
parts.append('<p class="vide">Fichier controles_%s.csv absent ou vide.</p>\n'
% esc(nom))
parts.append("<h3>Pose T</h3>\n")
parts.append(html_pose(pose))
parts.append("<h3>\u00c9carts d\u00e9taill\u00e9s</h3>\n")
if evals:
parts.append(html_ecarts(evals))
else:
parts.append('<p class="vide">Fichier eval_%s.csv absent ou vide.</p>\n'
% esc(nom))
parts.append("</section>\n")
parts.append('<footer>Rapport g\u00e9n\u00e9r\u00e9 par RapportControles.py \u00b7 '
'source : <code>%s</code></footer>\n</main>\n</body>\n</html>\n'
% esc(dossier))
sortie = os.path.join(dossier, "rapport.html")
with open(sortie, "w", encoding="utf-8", newline="\n") as f:
f.write("".join(parts))
return sortie, stats
def main(argv):
dossier = argv[1] if len(argv) > 1 else DOSSIER_DEFAUT
if not os.path.isfile(os.path.join(dossier, "rapport.json")):
print("rapport.json introuvable dans : %s" % dossier, file=sys.stderr)
return 1
sortie, stats = construire(dossier)
print("Rapport ecrit : %s" % sortie)
for nom, (nc, ne) in stats.items():
print(" %s : %d lignes de controles, %d lignes d'ecarts" % (nom, nc, ne))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))