#include "LPAnimEvalCommandlet.h" #include "Animation/AnimSequence.h" #include "AssetRegistry/AssetRegistryModule.h" #include "Dom/JsonObject.h" #include "HAL/FileManager.h" #include "Engine/SkeletalMesh.h" #include "LPAnimChecks.h" #include "LPPoseMeasure.h" #include "LPSkeletonProfile.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "Serialization/JsonSerializer.h" #include "Serialization/JsonWriter.h" DEFINE_LOG_CATEGORY_STATIC(LogLPAnimEval, Log, All); namespace { struct FBody { FString Name; FString Prefix; }; FString ClipPath(const FString& Dir, const FString& Name) { return FString::Printf(TEXT("%s/%s.%s"), *Dir, *Name, *Name); } TArray ParseBodies(const FString& Text) { TArray Bodies; TArray Items; Text.ParseIntoArray(Items, TEXT(","), true); for (const FString& Item : Items) { FString Name; FString Prefix; if (Item.Split(TEXT(":"), &Name, &Prefix)) { Bodies.Add({ Name.TrimStartAndEnd(), Prefix.TrimStartAndEnd() }); } } return Bodies; } // Clips de reference du dossier qui ont une copie pour chaque corps (prefixe du corps + nom). TArray ListClips(const FString& Dir, const TArray& Bodies) { IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); Registry.ScanPathsSynchronous({ Dir }, true); TArray Assets; Registry.GetAssetsByPath(FName(*Dir), Assets, false); TSet Names; for (const FAssetData& Asset : Assets) { if (Asset.AssetClassPath == UAnimSequence::StaticClass()->GetClassPathName()) { Names.Add(Asset.AssetName.ToString()); } } TArray Clips; for (const FString& Name : Names) { bool bCopy = false; bool bAll = true; for (const FBody& Body : Bodies) { bCopy |= Name.StartsWith(Body.Prefix); bAll &= Names.Contains(Body.Prefix + Name); } if (!bCopy && bAll) { Clips.Add(Name); } } Clips.Sort(); return Clips; } // Torsion : ecart de rotation (degres) des os Bend et Twist du profil entre le clip converti et la formule. // Les directions de segment ne voient pas la torsion d'un os autour de son axe ; cette mesure si. float TwistGap(const FLPPose& Raw, const FLPPose& Formula, FString& OutBone) { const FLPSkeletonProfile* Profile = LPSkeletonProfile::Find(Raw.Profile); float Worst = 0.f; if (!Profile) { return Worst; } for (const TCHAR* Role : { TEXT("upperarm"), TEXT("lowerarm"), TEXT("thigh") }) { for (const TCHAR* Side : { TEXT("_l"), TEXT("_r") }) { for (const FString& Name : { FString(Role) + Side, FString(Role) + TEXT("_twist") + Side }) { const int32 Bone = Raw.Names.IndexOfByKey(Profile->Bone(Name)); if (Bone == INDEX_NONE || !Formula.Component.IsValidIndex(Bone)) { continue; } const float Angle = FMath::RadiansToDegrees( Raw.Component[Bone].GetRotation().AngularDistance(Formula.Component[Bone].GetRotation())); if (Angle > Worst) { Worst = Angle; OutBone = Raw.Names[Bone].ToString(); } } } } return Worst; } bool SaveJson(const TSharedRef& Object, const FString& Path) { FString Text; const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Text); FJsonSerializer::Serialize(Object, Writer); return FFileHelper::SaveStringToFile(Text, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); } // Etape 3 : ajustement de la pose T et ses controles. bool CheckTPose(const FLPPose& Reference, const FLPPose& Template, const FBody& Body, const FString& PoseDir, const FString& Out, float SeuilPoseT, float SeuilSymetrie, float SeuilReglage, TArray& Failures) { const FString PosePath = FPaths::Combine(PoseDir, FString::Printf(TEXT("retarget_pose_%s.txt"), *Body.Name)); FLPTFitResult Fit; if (!LPPoseMeasure::WriteRetargetPose(Reference, Template, PosePath) || !LPPoseMeasure::TFitResult(Reference, Template, Fit)) { Failures.Add(FString::Printf(TEXT("%s : pose T non calculee"), *Body.Name)); return false; } const int32 Before = Failures.Num(); TSharedRef Report = MakeShared(); Report->SetStringField(TEXT("corps"), Body.Name); Report->SetStringField(TEXT("profil"), Template.Profile); Report->SetStringField(TEXT("fichier_pose"), PosePath); TArray> Segments; for (const FLPTFitSegment& Segment : Fit.Segments) { TSharedRef Item = MakeShared(); Item->SetStringField(TEXT("segment"), Segment.Name); Item->SetNumberField(TEXT("avant"), Segment.Before); Item->SetNumberField(TEXT("apres"), Segment.After); Item->SetBoolField(TEXT("ajuste"), Segment.bFit); Segments.Add(MakeShared(Item)); if (Segment.bFit && Segment.After > SeuilPoseT) { Failures.Add(FString::Printf(TEXT("%s : segment %s %.2f %% apres ajustement (seuil %.2f)"), *Body.Name, *Segment.Name, Segment.After, SeuilPoseT)); } } Report->SetArrayField(TEXT("segments"), Segments); TMap Degrees; TArray> Adjusts; for (const FLPTFitAdjust& Adjust : Fit.Adjusts) { Degrees.Add(Adjust.Bone, Adjust.Degrees); TSharedRef Item = MakeShared(); Item->SetStringField(TEXT("os"), Adjust.Bone.ToString()); Item->SetNumberField(TEXT("degres"), Adjust.Degrees); Adjusts.Add(MakeShared(Item)); if (Adjust.Degrees > SeuilReglage) { Failures.Add(FString::Printf(TEXT("%s : reglage %s %.1f deg (borne %.1f)"), *Body.Name, *Adjust.Bone.ToString(), Adjust.Degrees, SeuilReglage)); } } Report->SetArrayField(TEXT("reglages"), Adjusts); // Symetrie : chaque role _l face a son role _r, os par os. const FLPSkeletonProfile* Profile = LPSkeletonProfile::Find(Template.Profile); float WorstGap = 0.f; if (Profile) { for (const TPair>& Role : Profile->Roles) { if (!Role.Key.EndsWith(TEXT("_l"))) { continue; } const TArray& Right = Profile->Chain(Role.Key.LeftChop(2) + TEXT("_r")); for (int32 Index = 0; Index < FMath::Min(Role.Value.Num(), Right.Num()); ++Index) { const float* Left = Degrees.Find(Role.Value[Index]); const float* Other = Degrees.Find(Right[Index]); const float Gap = FMath::Abs((Left ? *Left : 0.f) - (Other ? *Other : 0.f)); WorstGap = FMath::Max(WorstGap, Gap); if (Gap > SeuilSymetrie) { Failures.Add(FString::Printf(TEXT("%s : reglages %s / %s asymetriques de %.2f deg"), *Body.Name, *Role.Value[Index].ToString(), *Right[Index].ToString(), Gap)); } } } } Report->SetNumberField(TEXT("ecart_symetrie_max"), WorstGap); Report->SetBoolField(TEXT("valide"), Failures.Num() == Before); SaveJson(Report, FPaths::Combine(Out, FString::Printf(TEXT("rapport_poseT_%s.json"), *Body.Name))); UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL poseT %s profil %s : %d segments, %d reglages, symetrie %.3f deg, %s"), *Body.Name, *Template.Profile, Fit.Segments.Num(), Fit.Adjusts.Num(), WorstGap, Failures.Num() == Before ? TEXT("OK") : TEXT("ECHEC")); return Failures.Num() == Before; } } ULPAnimEvalCommandlet::ULPAnimEvalCommandlet() { IsClient = false; IsEditor = true; IsServer = false; LogToConsole = true; } int32 ULPAnimEvalCommandlet::Main(const FString& Params) { FString ClipDir = TEXT("/Game/Quaternius/UAL"); FString RefClip = TEXT("A_TPose"); FString BodiesText = TEXT("Bridget:B_,Michael:M_"); FString PoseDir = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Measurement")); FString Out = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AnimPipeline"), TEXT("quaternius")); FString ClipsText; int32 Instants = 3; float SeuilPoseT = 1.f; float SeuilSymetrie = 0.5f; float SeuilReglage = 30.f; float SeuilEval = 1.f; float SeuilTorsion = -1.f; FParse::Value(*Params, TEXT("ClipDir="), ClipDir); FParse::Value(*Params, TEXT("RefClip="), RefClip); FParse::Value(*Params, TEXT("Bodies="), BodiesText, false); FParse::Value(*Params, TEXT("PoseDir="), PoseDir); FParse::Value(*Params, TEXT("Out="), Out); FParse::Value(*Params, TEXT("Clips="), ClipsText, false); FParse::Value(*Params, TEXT("Instants="), Instants); FParse::Value(*Params, TEXT("SeuilPoseT="), SeuilPoseT); FParse::Value(*Params, TEXT("SeuilSymetrie="), SeuilSymetrie); FParse::Value(*Params, TEXT("SeuilReglage="), SeuilReglage); FParse::Value(*Params, TEXT("SeuilEval="), SeuilEval); // Torsion Bend / Twist : relevee toujours, bloquante seulement si un seuil (degres) est donne. FParse::Value(*Params, TEXT("SeuilTorsion="), SeuilTorsion); // Controles independants (AP4), a chaque image. Seuils etalonnes sur les 43 clips Quaternius (CHG-208) : // au-dela du seuil, le clip est "a revoir" (liste pour controle visuel) ; au-dela de RejetFacteur fois le // seuil, il est "rejete" et bloque la livraison. FString MeshesText = TEXT("Bridget:/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp,Michael:/Game/Heavy/Daz/Michael/michael.michael"); FParse::Value(*Params, TEXT("Meshes="), MeshesText, false); const bool bChecks = !FParse::Param(*Params, TEXT("SansControles")); float SeuilSol = 3.f, SeuilGlissement = 3.f, SeuilGenou = 1.f, SeuilFlexion = 5.f, SeuilSaut = 10.f, SeuilPenetration = 1.f; float RejetFacteur = 3.f; FParse::Value(*Params, TEXT("RejetFacteur="), RejetFacteur); FParse::Value(*Params, TEXT("SeuilSol="), SeuilSol); FParse::Value(*Params, TEXT("SeuilGlissement="), SeuilGlissement); FParse::Value(*Params, TEXT("SeuilGenou="), SeuilGenou); FParse::Value(*Params, TEXT("SeuilFlexion="), SeuilFlexion); FParse::Value(*Params, TEXT("SeuilSaut="), SeuilSaut); FParse::Value(*Params, TEXT("SeuilPenetration="), SeuilPenetration); TMap Meshes; { TArray Items; MeshesText.ParseIntoArray(Items, TEXT(","), true); for (const FString& Item : Items) { FString Name, Path; if (Item.Split(TEXT(":"), &Name, &Path)) { Meshes.Add(Name, Path); } } } Instants = FMath::Max(Instants, 1); IFileManager::Get().MakeDirectory(*Out, true); IFileManager::Get().MakeDirectory(*PoseDir, true); const TArray Bodies = ParseBodies(BodiesText); TArray Failures; TSharedRef Summary = MakeShared(); Summary->SetStringField(TEXT("dossier_clips"), ClipDir); Summary->SetNumberField(TEXT("instants"), Instants); // Etape 3 : pose T de chaque corps, sur le clip de pose de reference. FLPPose Reference; if (!LPPoseMeasure::Sample(ClipPath(ClipDir, RefClip), 0.f, Reference) || Reference.Profile.IsEmpty()) { UE_LOG(LogLPAnimEval, Error, TEXT("ANIMEVAL reference absente ou squelette inconnu : %s"), *ClipPath(ClipDir, RefClip)); return 1; } for (const FBody& Body : Bodies) { FLPPose Template; if (!LPPoseMeasure::Sample(ClipPath(ClipDir, Body.Prefix + RefClip), 0.f, Template) || Template.Profile.IsEmpty()) { Failures.Add(FString::Printf(TEXT("%s : clip %s%s absent ou squelette inconnu"), *Body.Name, *Body.Prefix, *RefClip)); continue; } CheckTPose(Reference, Template, Body, PoseDir, Out, SeuilPoseT, SeuilSymetrie, SeuilReglage, Failures); } // Etape 5 : formule et clip converti contre la reference, a chaque instant. TArray Clips = ListClips(ClipDir, Bodies); if (!ClipsText.IsEmpty()) { TArray Wanted; ClipsText.ParseIntoArray(Wanted, TEXT(","), true); Clips = Clips.FilterByPredicate([&Wanted](const FString& Clip) { return Wanted.Contains(Clip); }); } Summary->SetNumberField(TEXT("clips"), Clips.Num()); TArray> BodyReports; TArray> ToReview; for (const FBody& Body : Bodies) { TArray Csv; Csv.Add(TEXT("clip,instant,formule_pire,formule_os,brut_pire,brut_os,torsion_deg,torsion_os")); float WorstFormula = 0.f; float WorstTwist = 0.f; float WorstRaw = 0.f; int32 Rejected = 0; for (const FString& Clip : Clips) { const FString RefPath = ClipPath(ClipDir, Clip); const UAnimSequence* Anim = LoadObject(nullptr, *RefPath); const float Length = Anim ? Anim->GetPlayLength() : 0.f; for (int32 Step = 0; Step < Instants; ++Step) { // Meme instant que la tournee de la sandbox (k / Instants de la duree). const float Fraction = static_cast(Step) / static_cast(Instants); const float Time = FMath::Clamp(Fraction, 0.f, 1.f) * Length; FLPPose RefPose; FLPPose Raw; FLPPose Formula; if (!LPPoseMeasure::Sample(RefPath, Time, RefPose) || !LPPoseMeasure::Sample(ClipPath(ClipDir, Body.Prefix + Clip), Time, Raw) || !LPPoseMeasure::TransferPose(RefPose, Raw, Formula)) { Failures.Add(FString::Printf(TEXT("%s : %s a %.2f non mesurable"), *Body.Name, *Clip, Fraction)); continue; } const FLPPoseMeasureReport FormulaCheck = LPPoseMeasure::Compare(*Body.Name, *Clip, RefPose, Formula); const FLPPoseMeasureReport RawCheck = LPPoseMeasure::Compare(*Body.Name, *Clip, RefPose, Raw); FString TwistBone; const float Twist = TwistGap(Raw, Formula, TwistBone); WorstTwist = FMath::Max(WorstTwist, Twist); Csv.Add(FString::Printf(TEXT("%s,%.2f,%.2f,%s,%.2f,%s,%.2f,%s"), *Clip, Fraction, FormulaCheck.WorstAnglePercent, *FormulaCheck.WorstAngleBone, RawCheck.WorstAnglePercent, *RawCheck.WorstAngleBone, Twist, *TwistBone)); if (SeuilTorsion >= 0.f && Twist > SeuilTorsion) { ++Rejected; Failures.Add(FString::Printf(TEXT("%s : %s a %.2f torsion %.2f deg (%s), seuil %.2f"), *Body.Name, *Clip, Fraction, Twist, *TwistBone, SeuilTorsion)); } WorstFormula = FMath::Max(WorstFormula, FormulaCheck.WorstAnglePercent); WorstRaw = FMath::Max(WorstRaw, RawCheck.WorstAnglePercent); if (FormulaCheck.WorstAnglePercent > SeuilEval || RawCheck.WorstAnglePercent > SeuilEval) { ++Rejected; Failures.Add(FString::Printf(TEXT("%s : %s a %.2f formule %.2f %% (%s), clip %.2f %% (%s)"), *Body.Name, *Clip, Fraction, FormulaCheck.WorstAnglePercent, *FormulaCheck.WorstAngleBone, RawCheck.WorstAnglePercent, *RawCheck.WorstAngleBone)); } } } FFileHelper::SaveStringArrayToFile(Csv, *FPaths::Combine(Out, FString::Printf(TEXT("eval_%s.csv"), *Body.Name))); // Controles independants, a chaque image de chaque clip converti. int32 ChecksFlagged = 0; if (bChecks) { FLPPose Template, RefT, BodyT; const USkeletalMesh* Mesh = Meshes.Contains(Body.Name) ? LoadObject(nullptr, *Meshes[Body.Name]) : nullptr; if (LPPoseMeasure::Sample(ClipPath(ClipDir, Body.Prefix + RefClip), 0.f, Template) && LPPoseMeasure::TPoses(Reference, Template, RefT, BodyT)) { const TArray Trunk = LPAnimChecks::TrunkCapsules(Mesh, BodyT); UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL controles %s : maillage %s, %d capsules de tronc"), *Body.Name, Mesh ? *Mesh->GetName() : TEXT("ABSENT"), Trunk.Num()); const float SelfGap = LPAnimChecks::SelfTest(Trunk, BodyT); UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL capsule auto-test %s : ecart %.4f cm"), *Body.Name, SelfGap); if (SelfGap < 0.f || SelfGap > 0.01f) { Failures.Add(FString::Printf(TEXT("%s : auto-test de penetration en echec (%.4f cm)"), *Body.Name, SelfGap)); } for (const FLPCheckCapsule& Capsule : Trunk) { UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL capsule %s %s : rayon %.1f cm, demi-longueur %.1f cm"), *Body.Name, *BodyT.Names[Capsule.Bone].ToString(), Capsule.Radius, Capsule.HalfLength); } TArray Lines; Lines.Add(TEXT("clip,images,sol_cm,sol_os,glissement_cm,glissement_os,genou_cm,genou_os,flexion_deg,flexion_os,saut_deg,saut_os,penetration_cm,penetration_os,verdict")); for (const FString& Clip : Clips) { const FLPClipChecks C = LPAnimChecks::CheckClip(ClipPath(ClipDir, Clip), ClipPath(ClipDir, Body.Prefix + Clip), RefT, BodyT, Trunk); TArray Over; bool bReject = !C.Error.IsEmpty(); if (bReject) { Over.Add(C.Error); } auto Test = [&](float Value, float Seuil, const TCHAR* Label, const FString& Bone) { if (Value > Seuil) { Over.Add(FString::Printf(TEXT("%s %.1f (%s)"), Label, Value, *Bone)); bReject |= Value > Seuil * RejetFacteur; } }; Test(C.Floor, SeuilSol, TEXT("sol cm"), C.FloorBone); Test(C.Slide, SeuilGlissement, TEXT("glissement cm"), C.SlideBone); Test(C.KneeBack, SeuilGenou, TEXT("genou cm"), C.KneeBone); Test(C.FlexGap, SeuilFlexion, TEXT("flexion deg"), C.FlexJoint); Test(C.Jump, SeuilSaut, TEXT("saut deg"), C.JumpBone); Test(C.Penetration, SeuilPenetration, TEXT("penetration cm"), C.PenetrationBone); const FString Verdict = Over.Num() == 0 ? TEXT("accepte") : bReject ? TEXT("rejete") : TEXT("a revoir"); Lines.Add(FString::Printf(TEXT("%s,%d,%.2f,%s,%.2f,%s,%.2f,%s,%.2f,%s,%.2f,%s,%.2f,%s,%s"), *Clip, C.Frames, C.Floor, *C.FloorBone, C.Slide, *C.SlideBone, C.KneeBack, *C.KneeBone, C.FlexGap, *C.FlexJoint, C.Jump, *C.JumpBone, C.Penetration, *C.PenetrationBone, *Verdict)); if (Over.Num() > 0) { const FString Detail = FString::Printf(TEXT("%s : %s : %s"), *Body.Name, *Clip, *FString::Join(Over, TEXT(", "))); if (bReject) { Failures.Add(Detail); } else { ++ChecksFlagged; ToReview.Add(MakeShared(Detail)); } } } FFileHelper::SaveStringArrayToFile(Lines, *FPaths::Combine(Out, FString::Printf(TEXT("controles_%s.csv"), *Body.Name))); } else { Failures.Add(FString::Printf(TEXT("%s : controles impossibles (pose T)"), *Body.Name)); } } TSharedRef Item = MakeShared(); Item->SetStringField(TEXT("corps"), Body.Name); Item->SetNumberField(TEXT("instants_mesures"), Csv.Num() - 1); Item->SetNumberField(TEXT("formule_pire"), WorstFormula); Item->SetNumberField(TEXT("clip_pire"), WorstRaw); Item->SetNumberField(TEXT("torsion_pire_deg"), WorstTwist); Item->SetNumberField(TEXT("instants_hors_seuil"), Rejected); Item->SetNumberField(TEXT("clips_a_revoir_controles"), ChecksFlagged); BodyReports.Add(MakeShared(Item)); UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL eval %s : %d instants, formule pire %.2f %%, clip pire %.2f %%, torsion pire %.2f deg, %d hors seuil"), *Body.Name, Csv.Num() - 1, WorstFormula, WorstRaw, WorstTwist, Rejected); } Summary->SetArrayField(TEXT("corps"), BodyReports); TArray> FailureList; for (const FString& Failure : Failures) { FailureList.Add(MakeShared(Failure)); UE_LOG(LogLPAnimEval, Warning, TEXT("ANIMEVAL echec %s"), *Failure); } Summary->SetArrayField(TEXT("echecs"), FailureList); // Clips a revoir : n'empechent pas la livraison, a regarder dans la sandbox (planches de la tournee). Summary->SetArrayField(TEXT("a_revoir"), ToReview); for (const TSharedPtr& Item : ToReview) { UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL a revoir %s"), *Item->AsString()); } Summary->SetBoolField(TEXT("valide"), Failures.Num() == 0); SaveJson(Summary, FPaths::Combine(Out, TEXT("rapport.json"))); UE_LOG(LogLPAnimEval, Display, TEXT("ANIMEVAL resultat %s : %d echec(s), rapports dans %s"), Failures.Num() == 0 ? TEXT("VALIDE") : TEXT("REFUSE"), Failures.Num(), *Out); return Failures.Num() == 0 ? 0 : 1; }