using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using NUnit.Framework;
using OpenCvSharp;
using OpenCvSharp.Aruco;
using ArucoDictionary = OpenCvSharp.Aruco.Dictionary;
namespace TIASshot.Tests {
/// <summary>
/// ChartDetector のテスト(AR マーカー検出・上下判定・固定判定・マスク生成)
///
/// fixture は実写画像ではなく,Dict4X4_50 の ID 40 / 41 をコードで描画した合成画像を使う.
/// DetectChart は「画像入力 → マスク出力」の純粋処理で,外部依存は
/// Config の Calib/ChartSetCriteria・Calib/ChartSetCount と showMessage コールバックだけであるため,
/// 合成画像で検出 API の等価性(ID・コーナー順序・幾何)を検証できる.
/// 実写のノイズ・照明ムラ・レンズ歪みは再現しないので,実機確認の代替ではない.
///
/// Config は静的シングルトンのため [NonParallelizable] とし,各テストの SetUp で必ず読み直す.
/// </summary>
[TestFixture]
[NonParallelizable]
public class ChartDetectorTests {
// 合成 fixture の寸法.ChartDetector が内部で使うモデル空間(810 x 1545)と同じにして
// ホモグラフィがほぼ恒等変換になるようにする
private const int ImageWidth = 810;
private const int ImageHeight = 1545;
// マーカーの描画サイズ.ChartDetector の PointsDst40 / PointsDst41 が示す 120px 四方に合わせる
private const int MarkerSizePx = 120;
// モデル空間上のマーカー位置(PointsDst40 / PointsDst41 の左上座標)
private const int Marker40X = 345;
private const int Marker40Y = 130;
private const int Marker41X = 345;
private const int Marker41Y = 1200;
private const int MarkerId40 = 40;
private const int MarkerId41 = 41;
// テスト用 Config の値
private const int ChartSetCount = 3;
private const float ChartSetCriteria = 4.0f;
// ChartSetCriteria を確実に超える移動量
private const int MovementOverCriteria = 20;
// ChartDetector が返すマスクの枚数(カラーチャートのパッチ数)
private const int ExpectedMaskCount = 24;
private const string UpsideDownMessage = "舌診チャートが上下逆方向です";
private static readonly string TestConfigXml =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<Config>" +
" <Calib>" +
" <ChartSetCriteria>" + ChartSetCriteria.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "</ChartSetCriteria>" +
" <ChartSetCount>" + ChartSetCount + "</ChartSetCount>" +
" </Calib>" +
"</Config>";
private string _tempConfigPath;
private List<string> _messages;
[SetUp]
public void SetUp() {
var tempPath = Path.GetTempFileName();
_tempConfigPath = tempPath + ".xml";
File.Move(tempPath, _tempConfigPath);
File.WriteAllText(_tempConfigPath, TestConfigXml, System.Text.Encoding.UTF8);
Config.Load(_tempConfigPath);
_messages = new List<string>();
}
[TearDown]
public void TearDown() {
if (_tempConfigPath != null && File.Exists(_tempConfigPath)) File.Delete(_tempConfigPath);
}
// ---------------------------------------------------------------
// テスト本体
// ---------------------------------------------------------------
/// <summary>
/// AR マーカーが 1 つも写っていない画像では検出が成立せず null を返す
/// </summary>
[Test]
public void DetectChart_マーカーが無い画像_nullを返す() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateBlankImage()) {
var masks = detector.DetectChart(img);
DisposeMasks(masks);
Assert.That(masks, Is.Null);
}
}
/// <summary>
/// ID 40 だけが写っている場合はマーカー座標が 8 点に満たないため null を返す
/// </summary>
[Test]
public void DetectChart_ID40のみの画像_nullを返す() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateBlankImage()) {
DrawMarker(img, MarkerId40, Marker40X, Marker40Y);
var masks = detector.DetectChart(img);
DisposeMasks(masks);
Assert.That(masks, Is.Null);
}
}
/// <summary>
/// ID 40 が ID 41 より下にある(チャートが上下逆)場合は null を返す
/// </summary>
[Test]
public void DetectChart_チャートが上下逆_nullを返す() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateUpsideDownChartImage()) {
var masks = detector.DetectChart(img);
DisposeMasks(masks);
Assert.That(masks, Is.Null);
}
}
/// <summary>
/// ID 40 が ID 41 より下にある場合は上下逆である旨が呼び出し側に通知される
/// </summary>
[Test]
public void DetectChart_チャートが上下逆_上下逆を通知する() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateUpsideDownChartImage()) {
DisposeMasks(detector.DetectChart(img));
Assert.That(_messages, Does.Contain(UpsideDownMessage));
}
}
/// <summary>
/// 固定判定が成立するまで(Calib/ChartSetCount フレーム分)は null を返す
///
/// 1 フレーム目は _lastPosition が原点のため移動量が閾値を超えてカウントが 0 に落ちる.
/// したがってマスクが返るのは ChartSetCount + 1 フレーム目となる.
/// </summary>
[Test]
public void DetectChart_固定判定が成立するまで_nullを返す() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y)) {
for (int frame = 1; frame <= ChartSetCount; frame++) {
var masks = detector.DetectChart(img);
DisposeMasks(masks);
Assert.That(masks, Is.Null, $"{frame} フレーム目ではまだ固定判定は成立しないはず");
}
}
}
/// <summary>
/// マーカーが安定して検出され続けると,固定判定の成立フレームで 24 枚のマスクが返る
/// </summary>
[Test]
public void DetectChart_マーカーが安定検出_24枚のマスクを返す() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y)) {
var masks = DetectUntilStable(detector, img);
try {
Assert.That(masks, Has.Count.EqualTo(ExpectedMaskCount));
} finally {
DisposeMasks(masks);
}
}
}
/// <summary>
/// 返るマスクはすべて入力画像と同じサイズである(入力画像座標系のマスクとして使える)
/// </summary>
[Test]
public void DetectChart_返るマスク_入力画像と同じサイズ() {
var detector = new ChartDetector(RecordMessage);
using (var img = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y)) {
var masks = DetectUntilStable(detector, img);
try {
Assert.That(masks, Is.All.Matches<Mat>(
m => m.Width == ImageWidth && m.Height == ImageHeight));
} finally {
DisposeMasks(masks);
}
}
}
/// <summary>
/// マーカーが Calib/ChartSetCriteria を超えて動くと固定判定カウントがリセットされる
///
/// 安定フレームを ChartSetCount 回与えた直後にマーカーを動かすと,
/// リセットが無ければそのフレームでマスクが返るが,リセットされるため null になる.
/// </summary>
[Test]
public void DetectChart_基準を超えて動く_固定判定がリセットされる() {
var detector = new ChartDetector(RecordMessage);
using (var stillImg = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y))
using (var movedImg = CreateChartImage(
Marker40X + MovementOverCriteria, Marker40Y,
Marker41X + MovementOverCriteria, Marker41Y)) {
for (int frame = 1; frame <= ChartSetCount; frame++) {
DisposeMasks(detector.DetectChart(stillImg));
}
var masks = detector.DetectChart(movedImg);
DisposeMasks(masks);
Assert.That(masks, Is.Null);
}
}
/// <summary>
/// 固定判定成立後の検出(ホモグラフィ計算・ワープ)で生成する中間 Mat を呼び出し内で解放し,
/// ネイティブメモリを GC ファイナライザ任せで滞留させないこと
///
/// GC を止めた状態(NoGCRegion)でマスク生成まで進む検出を繰り返し,プライベートメモリの増分を見る.
/// 中間 Mat(とくに 810 x 1545 x 3 のワープ画像 約 3.75MB)が未解放だと呼び出し回数に比例して増える.
/// 返されたマスクはテスト側で解放する.Windows ヒープは大きなブロックを解放時に OS へ返すため,
/// 解放されていれば増分はほぼ 0 になる.
/// </summary>
[Test]
[Category("Memory")]
public void DetectChart_固定判定成立後の検出を繰り返す_中間Matのネイティブメモリを滞留させない() {
const int Iterations = 10;
const long LeakThresholdBytes = 20L * 1024 * 1024; // 未解放なら約 37MB 増える
var detector = new ChartDetector(RecordMessage);
using (var img = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y)) {
// Arrange: 固定判定を成立させ,ネイティブ側の初回確保(検出器の内部バッファ等)も済ませる
DisposeMasks(DetectUntilStable(detector, img));
for (int i = 0; i < 3; i++) DisposeMasks(detector.DetectChart(img));
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.That(GC.TryStartNoGCRegion(64L * 1024 * 1024), Is.True, "NoGCRegion を開始できること(前提)");
long before, after;
try {
before = GetPrivateBytes();
// Act
for (int i = 0; i < Iterations; i++) {
var masks = detector.DetectChart(img);
Assert.That(masks, Has.Count.EqualTo(ExpectedMaskCount), "毎回マスク生成まで進むこと(前提)");
DisposeMasks(masks);
}
after = GetPrivateBytes();
} finally {
if (System.Runtime.GCSettings.LatencyMode == System.Runtime.GCLatencyMode.NoGCRegion) GC.EndNoGCRegion();
}
// Assert
Assert.That(after - before, Is.LessThan(LeakThresholdBytes),
$"{Iterations} 回の検出でプライベートメモリが {(after - before) / 1024 / 1024}MB 増えた(中間 Mat の未解放)");
}
}
// ---------------------------------------------------------------
// ヘルパー
// ---------------------------------------------------------------
private static long GetPrivateBytes() {
using (var process = Process.GetCurrentProcess()) {
return process.PrivateMemorySize64;
}
}
private void RecordMessage(string message) {
_messages.Add(message);
}
/// <summary>
/// 固定判定が成立するまで同じ画像を与え,成立フレームの結果を返す
/// </summary>
private static List<Mat> DetectUntilStable(ChartDetector detector, Mat img) {
List<Mat> masks = null;
for (int frame = 0; frame <= ChartSetCount; frame++) {
DisposeMasks(masks);
masks = detector.DetectChart(img);
}
return masks;
}
private static void DisposeMasks(List<Mat> masks) {
if (masks == null) return;
foreach (var mask in masks) mask.Dispose();
}
/// <summary>
/// マーカーを含まない白い BGR 画像を作る
/// </summary>
private static Mat CreateBlankImage() {
return new Mat(ImageHeight, ImageWidth, MatType.CV_8UC3, Scalar.All(255));
}
/// <summary>
/// ID 40(上)と ID 41(下)を指定位置に描いた合成チャート画像を作る
/// </summary>
private static Mat CreateChartImage(int x40, int y40, int x41, int y41) {
var img = CreateBlankImage();
try {
DrawMarker(img, MarkerId40, x40, y40);
DrawMarker(img, MarkerId41, x41, y41);
return img;
} catch {
img.Dispose();
throw;
}
}
/// <summary>
/// ID 40 と ID 41 の上下を入れ替えた(チャートが上下逆の)合成画像を作る
/// </summary>
private static Mat CreateUpsideDownChartImage() {
return CreateChartImage(Marker40X, Marker41Y, Marker41X, Marker40Y);
}
/// <summary>
/// Dict4X4_50 のマーカーを画像上の指定位置に描画する
/// </summary>
private static void DrawMarker(Mat canvas, int markerId, int x, int y) {
using (ArucoDictionary dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryType.Dict4X4_50))
using (var marker = new Mat())
using (var markerBgr = new Mat())
using (var roi = new Mat(canvas, new Rect(x, y, MarkerSizePx, MarkerSizePx))) {
// borderBits = 1(Dict4X4_50 の既定).sidePixels には黒枠を含んだ全体サイズを指定する
dict.GenerateImageMarker(markerId, MarkerSizePx, marker, 1);
Cv2.CvtColor(marker, markerBgr, ColorConversionCodes.GRAY2BGR);
markerBgr.CopyTo(roi);
}
}
}
}