AP4 (reste) : RapportControles.py
Phase AP4 (CHG-208). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
439bcb1006
commit
e4b8752549
1 changed files with 357 additions and 0 deletions
357
Tools/anim/RapportControles.py
Normal file
357
Tools/anim/RapportControles.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
#!/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
|
||||
|
||||
DOSSIER_DEFAUT = r"D:\Game\LifePlay-master 5.5\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))
|
||||
Loading…
Reference in a new issue