Host the avatar test as a test suite; drop visual_novel_core dependency
- The Live2D avatar integration test (driver, overlay, test model "ren") moves here from the separate live2d_test mod as test_suites/avatar. It is mounted as the virtual mod live2d__avatar only with --itest=live2d_avatar; normal launches never load it. - The runtime library needs only the interface mod; the content mod dependency is gone.
This commit is contained in:
1 parent
a9d4417fce
commit
bec5743f13
25 files changed
+13542
-2
No files matched your search
@@ -0,0 +1,290 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Live2D.Cubism.Framework;
|
||||
using Cthangover.Live2D.Cubism.Services;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.Live2DTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Интеграционный тест Live2D-аватара: создаёт модель через мод-пайплайн
|
||||
/// (тот же путь, что и <see cref="Live2DOverlay"/>), держит руки опущенными
|
||||
/// секунду, затем поднимает правую руку через <c>ParamArmR01</c> и
|
||||
/// проверяет смену позы компьютерным зрением по скриншотам вьюпорта:
|
||||
/// <list type="bullet">
|
||||
/// <item>детерминизм — два кадра статичной позы идентичны попиксельно;</item>
|
||||
/// <item>видимость — команда смены позы перекрашивает значимую часть кадра;</item>
|
||||
/// <item>силуэт — в верхнем боковом секторе (зона над плечом) площадь
|
||||
/// аватара растёт после подъёма руки;</item>
|
||||
/// <item>удержание — параметр держит поднятое значение между кадрами
|
||||
/// (регрессия на Load/SaveParameters-брекет).</item>
|
||||
/// </list>
|
||||
/// Скриншоты сохраняются в tests/artifacts/live2d_avatar. Инстанцируется
|
||||
/// игровой интеграционной сценой через TscnScenes (мод — единственное место
|
||||
/// с компайл-тайм доступом к Live2D-обёртке); сам печатает [TEST_RESULT]
|
||||
/// и завершает дерево с кодом выхода.
|
||||
/// </summary>
|
||||
public partial class Live2DAvatarTestDriver : Node2D
|
||||
{
|
||||
private const string ModId = "live2d__avatar";
|
||||
private const string ModelPath = "models/runtime/ren.model3.json";
|
||||
private const string LeftArmParam = "ParamArmL01";
|
||||
private const string RightArmParam = "ParamArmR01";
|
||||
|
||||
/// <summary>Сколько держим опущенные руки до скриншота (по ТЗ — секунду).</summary>
|
||||
private const float LoweredHoldSeconds = 1.0f;
|
||||
|
||||
/// <summary>Пауза между кадрами проверки детерминизма.</summary>
|
||||
private const float StabilityGapSeconds = 0.4f;
|
||||
|
||||
/// <summary>Ожидание растеризации поднятой руки.</summary>
|
||||
private const float RaisedSettleSeconds = 0.8f;
|
||||
|
||||
/// <summary>Порог «далёкий от фона» пиксель силуэта, 0..1.</summary>
|
||||
private const float SilhouetteTolerance = 0.2f;
|
||||
|
||||
/// <summary>Порог значимого изменения канала для diff-подсчёта.</summary>
|
||||
private const float DiffTolerance = 0.1f;
|
||||
|
||||
private int passed;
|
||||
private int failed;
|
||||
private string artifactDir;
|
||||
|
||||
public override void _Ready() => _ = RunAsync();
|
||||
|
||||
private async System.Threading.Tasks.Task RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
GameLogger.Log("TEST", "Live2DAvatarTestDriver: starting Live2D avatar integration test");
|
||||
artifactDir = ProjectSettings.GlobalizePath("res://tests/artifacts/live2d_avatar");
|
||||
Directory.CreateDirectory(artifactDir);
|
||||
|
||||
await RunTest();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Fail($"unexpected crash: {ex.GetType().Name}: {ex.Message}");
|
||||
GameLogger.Log("TEST", ex.StackTrace ?? "(no stack)");
|
||||
}
|
||||
|
||||
GameLogger.Log("TEST", $"Live2DAvatarTestDriver: {passed} passed, {failed} failed");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=live2d_avatar passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RunTest()
|
||||
{
|
||||
Pass($"driver started for mod '{ModId}'");
|
||||
|
||||
|
||||
// Тот же путь создания, что в продовом оверлее; моргание выключено,
|
||||
// чтобы кадры были пиксельно детерминированы.
|
||||
var model = Live2DService.CreateModel(this, new Live2DModelConfig
|
||||
{
|
||||
ModelPath = ModelPath,
|
||||
ModId = ModId,
|
||||
NodeName = "Live2DTestAvatar",
|
||||
EnableEyeBlink = false
|
||||
});
|
||||
|
||||
if (model == null || model.Parameters.Length == 0)
|
||||
{
|
||||
Fail("avatar creation failed (no parameters)");
|
||||
}
|
||||
Pass($"avatar created: {model.Parameters.Length} parameters, canvas {model.CanvasWidth:F0}x{model.CanvasHeight:F0}");
|
||||
|
||||
var leftArm = model.FindParameter(LeftArmParam);
|
||||
var rightArm = model.FindParameter(RightArmParam);
|
||||
if (leftArm == null || rightArm == null)
|
||||
{
|
||||
Fail($"arm parameters not found ('{LeftArmParam}' / '{RightArmParam}')");
|
||||
return;
|
||||
}
|
||||
Pass($"{LeftArmParam} [{leftArm.MinimumValue}..{leftArm.MaximumValue}], {RightArmParam} [{rightArm.MinimumValue}..{rightArm.MaximumValue}]");
|
||||
|
||||
// Фаза 1 — руки опущены, держим ~1 секунду.
|
||||
model.SetLiveParameter(LeftArmParam, leftArm.MinimumValue);
|
||||
model.SetLiveParameter(RightArmParam, rightArm.MinimumValue);
|
||||
await Delay(LoweredHoldSeconds);
|
||||
|
||||
var lowered = await Capture();
|
||||
await Delay(StabilityGapSeconds);
|
||||
var stability = await Capture();
|
||||
|
||||
// Фаза 2 — поднимаем руку.
|
||||
model.SetLiveParameter(RightArmParam, rightArm.MaximumValue);
|
||||
await Delay(RaisedSettleSeconds);
|
||||
var raised = await Capture();
|
||||
|
||||
SavePng("01_lowered.png", lowered);
|
||||
SavePng("02_lowered_stability.png", stability);
|
||||
SavePng("03_raised.png", raised);
|
||||
|
||||
Analyze(model, lowered, stability, raised);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Компьютерное зрение по трём кадрам: детерминизм статичной позы,
|
||||
/// видимая площадь смены позы, рост силуэта над плечом и удержание
|
||||
/// параметром поднятого значения.
|
||||
/// </summary>
|
||||
private void Analyze(
|
||||
CubismModelNode model, Image lowered, Image stability, Image raised)
|
||||
{
|
||||
var bg = lowered.GetPixel(2, 2);
|
||||
Pass($"background reference color: {bg}");
|
||||
|
||||
var sil = SilhouetteBounds(lowered, bg);
|
||||
if (sil.Size.X <= 0 || sil.Size.Y <= 0)
|
||||
{
|
||||
Fail("avatar silhouette not found on the lowered frame");
|
||||
return;
|
||||
}
|
||||
Pass($"silhouette bbox: pos={sil.Position} size={sil.Size}");
|
||||
|
||||
int framePixels = lowered.GetWidth() * lowered.GetHeight();
|
||||
|
||||
// 1) Детерминизм: статичная поза должна рендериться идентично.
|
||||
int stabilityDiff = CountChangedPixels(lowered, stability);
|
||||
double stabilityRatio = stabilityDiff / (double)framePixels;
|
||||
if (stabilityRatio <= 0.001)
|
||||
Pass($"static pose deterministic: {stabilityDiff} changed px ({stabilityRatio:P3})");
|
||||
else
|
||||
Fail($"static pose not deterministic: {stabilityDiff} changed px ({stabilityRatio:P3}) — ambient animation is on");
|
||||
|
||||
// 2) Команда смены позы должна перекрасить значимую часть кадра.
|
||||
int poseDiff = CountChangedPixels(lowered, raised);
|
||||
double poseRatio = poseDiff / (double)framePixels;
|
||||
if (poseRatio >= 0.003)
|
||||
Pass($"pose change visible: {poseDiff} changed px ({poseRatio:P3})");
|
||||
else
|
||||
Fail($"pose change too small: {poseDiff} changed px ({poseRatio:P3})");
|
||||
|
||||
// 3) CV: поднятая рука добавляет силуэт ЗА пределами опущенного
|
||||
// bbox'а на высоте плеча (лево или право — модель может быть
|
||||
// зеркальна зрителю). Полосы фиксированы по bbox опущенной позы,
|
||||
// поэтому не съезжают вместе с bbox поднятой руки.
|
||||
int stripH = System.Math.Max(1, (int)(sil.Size.Y * 0.30f));
|
||||
int stripY = (int)(sil.Position.Y + sil.Size.Y * 0.25f);
|
||||
int stripW = System.Math.Max(1, (int)(sil.Size.X * 2.0f));
|
||||
int margin = 4;
|
||||
int leftX = System.Math.Max(0, (int)sil.Position.X - margin - stripW);
|
||||
int rightX = System.Math.Min(lowered.GetWidth() - stripW, (int)sil.End.X + margin);
|
||||
var leftStrip = new Rect2I(leftX, stripY, stripW, stripH);
|
||||
var rightStrip = new Rect2I(rightX, stripY, stripW, stripH);
|
||||
int loweredLeft = CountModelPixels(lowered, leftStrip, bg);
|
||||
int raisedLeft = CountModelPixels(raised, leftStrip, bg);
|
||||
int loweredRight = CountModelPixels(lowered, rightStrip, bg);
|
||||
int raisedRight = CountModelPixels(raised, rightStrip, bg);
|
||||
int gain = System.Math.Max(raisedLeft - loweredLeft, raisedRight - loweredRight);
|
||||
int threshold = System.Math.Max(300, (int)(stripW * stripH * 0.02f));
|
||||
GameLogger.Log("TEST",
|
||||
$" side strips @shoulder: left {loweredLeft}->{raisedLeft}, right {loweredRight}->{raisedRight} px (strip {stripW}x{stripH}, need {threshold})");
|
||||
if (gain >= threshold)
|
||||
Pass($"raised arm silhouette appears beside the body: +{gain} px");
|
||||
else
|
||||
Fail($"no raised-arm silhouette beside the body: +{gain} px (threshold {threshold})");
|
||||
// 4) Параметр держит поднятое значение между кадрами
|
||||
// (регрессия на Load/SaveParameters-брекет и SetLiveParameter).
|
||||
var armNow = model.FindParameter(RightArmParam);
|
||||
if (armNow != null && System.Math.Abs(armNow.Value - armNow.MaximumValue) < 0.001f)
|
||||
Pass($"{RightArmParam} holds MaximumValue across frames ({armNow.Value})");
|
||||
else
|
||||
Fail($"{RightArmParam} did not hold MaximumValue (value={armNow?.Value})");
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task Delay(float seconds) =>
|
||||
await ToSignal(GetTree().CreateTimer(seconds), SceneTreeTimer.SignalName.Timeout);
|
||||
|
||||
/// <summary>Ждёт обработки кадра и снимает вьюпорт в RGBA8.</summary>
|
||||
private async System.Threading.Tasks.Task<Image> Capture()
|
||||
{
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
var image = GetViewport().GetTexture().GetImage();
|
||||
image.Convert(Image.Format.Rgba8);
|
||||
return image;
|
||||
}
|
||||
|
||||
private void SavePng(string fileName, Image image)
|
||||
{
|
||||
var path = Path.Combine(artifactDir, fileName);
|
||||
if (image.SavePng(path) == Error.Ok)
|
||||
Pass($"screenshot saved: {path}");
|
||||
else
|
||||
Fail($"SavePng failed for '{path}'");
|
||||
}
|
||||
|
||||
private static Rect2 SilhouetteBounds(Image image, Color bg)
|
||||
{
|
||||
byte[] data = image.GetData();
|
||||
int w = image.GetWidth(), h = image.GetHeight();
|
||||
int minX = int.MaxValue, minY = int.MaxValue, maxX = -1, maxY = -1;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = (y * w + x) * 4;
|
||||
if (IsFarFromBackground(data, i, bg))
|
||||
{
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? new Rect2(0, 0, 0, 0) : new Rect2(minX, minY, maxX - minX + 1, maxY - minY + 1);
|
||||
}
|
||||
|
||||
private static int CountModelPixels(Image image, Rect2I region, Color bg)
|
||||
{
|
||||
byte[] data = image.GetData();
|
||||
int w = image.GetWidth();
|
||||
int count = 0;
|
||||
for (int y = region.Position.Y; y < region.End.Y; y++)
|
||||
{
|
||||
for (int x = region.Position.X; x < region.End.X; x++)
|
||||
{
|
||||
if (IsFarFromBackground(data, (y * w + x) * 4, bg)) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountChangedPixels(Image a, Image b)
|
||||
{
|
||||
byte[] da = a.GetData(), db = b.GetData();
|
||||
int count = 0;
|
||||
int limit = System.Math.Min(da.Length, db.Length);
|
||||
for (int i = 0; i < limit; i += 4)
|
||||
{
|
||||
if (System.Math.Abs(da[i] - db[i]) > DiffTolerance * 255f
|
||||
|| System.Math.Abs(da[i + 1] - db[i + 1]) > DiffTolerance * 255f
|
||||
|| System.Math.Abs(da[i + 2] - db[i + 2]) > DiffTolerance * 255f)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool IsFarFromBackground(byte[] data, int i, Color bg) =>
|
||||
System.Math.Abs(data[i] / 255f - bg.R) > SilhouetteTolerance
|
||||
|| System.Math.Abs(data[i + 1] / 255f - bg.G) > SilhouetteTolerance
|
||||
|| System.Math.Abs(data[i + 2] / 255f - bg.B) > SilhouetteTolerance;
|
||||
|
||||
private void Pass(string msg)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $" [PASS] {msg}");
|
||||
}
|
||||
|
||||
private void Fail(string msg)
|
||||
{
|
||||
failed++;
|
||||
GameLogger.Log("TEST", $" [FAIL] {msg}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Live2D.Cubism.Framework;
|
||||
using Cthangover.Live2D.Cubism.Services;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
namespace Mods.Live2DTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene-driven replacement for the old live2d_test subscription snippet.
|
||||
/// The overlay root is added to a shell slot by <see cref="UiHost"/>; it
|
||||
/// spawns the Cubism model into the <c>Lastground</c> anchor and keeps a
|
||||
/// transparent drag handle on top so the player can steer the model's head
|
||||
/// parameters with the mouse. Created per <c>home_outside</c> visit
|
||||
/// (once=false) and destroyed together with the overlay.
|
||||
/// </summary>
|
||||
public partial class Live2DOverlay : ModWidget
|
||||
{
|
||||
private const string ModelPath = "models/runtime/ren.model3.json";
|
||||
private const string ModelNodeName = "Live2DTestModel";
|
||||
private const string ContainerNodeName = "Lastground";
|
||||
|
||||
private CubismModelNode model;
|
||||
private ColorRect dragHandle;
|
||||
private bool dragging;
|
||||
private float mouseStartX;
|
||||
private float mouseStartY;
|
||||
private float baseValueX;
|
||||
private float baseValueY;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore;
|
||||
|
||||
var container = UiHost.Instance?.FindNode(ContainerNodeName);
|
||||
if (container == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D_TEST", $"Live2DOverlay: container '{ContainerNodeName}' not found", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
model = Live2DService.CreateModel(container, ModelPath, ModelNodeName, "live2d__avatar");
|
||||
if (model == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D_TEST", "Live2DOverlay: model creation failed", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
dragHandle = new ColorRect
|
||||
{
|
||||
Name = "Live2DDragPanel",
|
||||
MouseFilter = MouseFilterEnum.Stop,
|
||||
Color = new Color(0, 1f, 0, 0f),
|
||||
Size = new Vector2(400, 400),
|
||||
AnchorLeft = 0.5f,
|
||||
AnchorRight = 0.5f,
|
||||
AnchorTop = 0.5f,
|
||||
AnchorBottom = 0.5f,
|
||||
OffsetLeft = -200f,
|
||||
OffsetRight = 200f,
|
||||
OffsetTop = -200f,
|
||||
OffsetBottom = -200f
|
||||
};
|
||||
AddChild(dragHandle);
|
||||
dragHandle.GuiInput += OnDragInput;
|
||||
}
|
||||
|
||||
protected override void Destruct()
|
||||
{
|
||||
if (model != null && GodotObject.IsInstanceValid(model))
|
||||
model.QueueFree();
|
||||
model = null;
|
||||
}
|
||||
|
||||
private void OnDragInput(InputEvent @event)
|
||||
{
|
||||
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
|
||||
{
|
||||
if (mb.Pressed)
|
||||
{
|
||||
dragging = true;
|
||||
mouseStartX = mb.GlobalPosition.X;
|
||||
mouseStartY = mb.GlobalPosition.Y;
|
||||
baseValueX = model.FindParameter("ParamAngleX")?.Value ?? 0f;
|
||||
baseValueY = model.FindParameter("ParamAngleY")?.Value ?? 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
dragging = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragging || @event is not InputEventMouseMotion mm || model == null)
|
||||
return;
|
||||
|
||||
var targetX = model.FindParameter("ParamAngleX");
|
||||
var targetY = model.FindParameter("ParamAngleY");
|
||||
var deltaX = (mm.GlobalPosition.X - mouseStartX) / 30f;
|
||||
var deltaY = (mm.GlobalPosition.Y - mouseStartY) / 30f;
|
||||
|
||||
if (targetX != null)
|
||||
model.SetLiveParameter("ParamAngleX", Mathf.Clamp(baseValueX + deltaX, targetX.MinimumValue, targetX.MaximumValue));
|
||||
if (targetY != null)
|
||||
model.SetLiveParameter("ParamAngleY", Mathf.Clamp(baseValueY + deltaY, targetY.MinimumValue, targetY.MaximumValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user