#!/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 # 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 ( '
\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))