diff --git a/Tools/anim/RapportControles.py b/Tools/anim/RapportControles.py new file mode 100644 index 0000000..94a569f --- /dev/null +++ b/Tools/anim/RapportControles.py @@ -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 : /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 ( + '
\n' + '

Rapport des contr\u00f4les d\'animations

\n' + '
%s
\n' + '
\n' + '
G\u00e9n\u00e9r\u00e9 le
%s
\n' + '
Dossier des clips
%s
\n' + '
Clips
%s
\n' + '
Instants par clip
%s
\n' + '
\n
\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 = ['
\n

\u00c9checs

\n'] + if echecs: + sortie.append('\n") + else: + sortie.append('

Aucun \u00e9chec.

\n') + sortie.append("
\n
\n

Clips \u00e0 revoir

\n") + if revoir: + sortie.append('\n") + else: + sortie.append('

Aucun clip \u00e0 revoir.

\n') + sortie.append("
\n") + return "".join(sortie) + + +def html_legende(): + items = [] + for _, _, titre, unite, seuil in MESURES: + items.append("
  • %s %s %s
  • " % ( + esc(titre), esc(fmt(seuil, 0)), esc(unite))) + return ( + '
    \n

    L\u00e9gende des seuils

    \n' + '\n' + '

    Une mesure au-del\u00e0 du seuil donne ' + '\u00e0 revoir ; au-del\u00e0 de %s fois le seuil, ' + 'rejet\u00e9. ' + 'Dans chaque cellule : la valeur, puis l\'os concern\u00e9 en petit.

    \n' + '
    \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 '
    %s
    \n' % "".join( + '
    %s%s
    ' + % (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 = "ClipImages" + "".join( + "%s (%s)" % (esc(t), esc(u)) + for _, _, t, u, _ in MESURES) + "Verdict" + corps = [] + for r in lignes: + verdict = r.get("verdict", "") + cls = {"rejete": "v-rejete", "a revoir": "v-revoir"}.get(verdict, "v-accepte") + cases = ['%s' % esc(r.get("clip", "")), + '%s' % 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( + '%s%s' % ( + classe_mesure(v, seuil), esc(fmt(v)), + ('%s' % esc(os_)) if os_ else "")) + cases.append('%s' % ( + esc(verdict.replace(" ", "")), esc(verdict))) + corps.append('%s' % (cls, "".join(cases))) + return ('
    \n%s\n' + '\n%s\n
    \n') % (tete, "\n".join(corps)) + + +def html_pose(pose): + if not pose: + return '

    Pas de rapport de pose T pour ce corps.

    \n' + seg = pose.get("segments") or [] + lignes = [] + for s in seg: + cls = "ajuste" if s.get("ajuste") else "" + lignes.append( + '%s%s%s' + '%s' % ( + 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 = ['%s%s' % ( + esc(r.get("os", "")), esc(fmt(r.get("degres")))) for r in reglages] + valide = pose.get("valide") + return ( + '

    Profil : %s \u00b7 sym\u00e9trie max : %s \u00b7 pose T : ' + '%s

    \n' + '
    \n' + '

    Segments (\u00e9cart en %% de 180\u00b0, avant / apr\u00e8s ajustement)

    ' + '
    ' + '%s
    SegmentAvantApr\u00e8s
    \n' + '

    R\u00e9glages appliqu\u00e9s (degr\u00e9s)

    ' + '
    ' + '%s
    OsDegr\u00e9s
    \n
    \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 + 'Aucun r\u00e9glage.') + + +def html_ecarts(lignes): + tete = ("ClipInstant (s)Formule (%)Clip brut (%)" + "Torsion (\u00b0)") + corps = [] + for r in lignes: + corps.append( + '%s%s' + '%s%s' + '%s%s' + '%s%s' % ( + 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 ( + '
    \u00c9carts formule / clip / torsion par clip et par ' + 'instant (%d lignes)\n
    ' + '%s\n%s\n
    \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 = ["\n\n\n\n" + "\n" + "\n" + "Rapport des contr\u00f4les d'animations\n" + "\n\n\n
    \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('
    \n

    Corps : %s

    \n' + % (esc(nom), esc(nom))) + parts.append(html_resume_corps(c)) + parts.append("

    Contr\u00f4les par clip

    \n") + if ctrl: + parts.append(html_controles(ctrl)) + else: + parts.append('

    Fichier controles_%s.csv absent ou vide.

    \n' + % esc(nom)) + parts.append("

    Pose T

    \n") + parts.append(html_pose(pose)) + parts.append("

    \u00c9carts d\u00e9taill\u00e9s

    \n") + if evals: + parts.append(html_ecarts(evals)) + else: + parts.append('

    Fichier eval_%s.csv absent ou vide.

    \n' + % esc(nom)) + parts.append("
    \n") + + parts.append('\n
    \n\n\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))