2228 lines
96 KiB
C#
2228 lines
96 KiB
C#
using System.Diagnostics;
|
|
using System.Drawing;
|
|
using System.Drawing.Imaging;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Tornado3_2026Election.Domain;
|
|
using Tornado3_2026Election.Services;
|
|
|
|
internal static class LiveCutValidation
|
|
{
|
|
public static async Task<int> RunAsync(string[] args)
|
|
{
|
|
var options = LiveCutValidationOptions.Parse(args);
|
|
Directory.CreateDirectory(options.OutputPath);
|
|
|
|
Console.WriteLine("Karisma live-cut validation starting.");
|
|
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
|
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
|
|
|
|
var logService = new LogService();
|
|
var cutDebugStateStore = new CutDebugStateStore();
|
|
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
|
|
{
|
|
Console.WriteLine("Karisma adapter is not available. Validation cannot continue.");
|
|
return 1;
|
|
}
|
|
|
|
var station = new BroadcastStationProfile
|
|
{
|
|
Id = "TJB",
|
|
Name = "TJB",
|
|
LogoAssetPath = options.StationLogoPath,
|
|
RegionFilters = ["대전", "세종", "충남"]
|
|
};
|
|
|
|
var cutItems = new FormatCatalogService().GetAll()
|
|
.Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
|
|
.SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
|
|
.ToList();
|
|
|
|
cutItems = ApplyTemplateFilter(cutItems, options.Filter);
|
|
|
|
if (options.Limit is int limit && limit > 0)
|
|
{
|
|
cutItems = cutItems.Take(limit).ToList();
|
|
}
|
|
|
|
Console.WriteLine($"- Cuts: {cutItems.Count}");
|
|
Console.WriteLine();
|
|
|
|
var pgmWindow = TryFindPgmWindow();
|
|
var results = new List<LiveCutValidationResult>();
|
|
|
|
try
|
|
{
|
|
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
for (var index = 0; index < cutItems.Count; index++)
|
|
{
|
|
var item = cutItems[index];
|
|
var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
|
|
var result = new LiveCutValidationResult
|
|
{
|
|
Index = index + 1,
|
|
TemplateId = item.Template.Id,
|
|
TemplateName = item.Template.Name,
|
|
CutName = item.Cut.Name,
|
|
Channel = item.Template.RecommendedChannel.ToString(),
|
|
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
|
|
OutputVisibleInPgm = pgmWindow is not null &&
|
|
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
|
|
};
|
|
|
|
Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id}");
|
|
|
|
try
|
|
{
|
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
|
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
|
|
|
var snapshotA = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
|
|
var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates);
|
|
|
|
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
|
|
|
result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
|
|
result.HashA = CapturePgm(pgmWindow, result.CaptureAPath, !result.OutputVisibleInPgm);
|
|
|
|
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
|
|
|
result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
|
|
result.HashB = CapturePgm(pgmWindow, result.CaptureBPath, !result.OutputVisibleInPgm);
|
|
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
|
|
result.Success = true;
|
|
result.Detail = result.OutputVisibleInPgm
|
|
? (result.VisualChanged ? "A/B capture changed" : "A/B capture hash identical")
|
|
: "VideoWall output is not visible in the current PGM window";
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
result.Success = false;
|
|
result.Detail = exception.Message;
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
await adapter.OutAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
|
results.Add(result);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
if (adapter is IDisposable disposable)
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
}
|
|
|
|
WriteReports(options, results);
|
|
|
|
var successCount = results.Count(result => result.Success);
|
|
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
|
var unchangedCount = results.Count(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged);
|
|
var failureCount = results.Count(result => !result.Success);
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("Summary");
|
|
Console.WriteLine($"- Success: {successCount}/{results.Count}");
|
|
Console.WriteLine($"- Visual Changed: {changedCount}");
|
|
Console.WriteLine($"- Unchanged Captures: {unchangedCount}");
|
|
Console.WriteLine($"- Failures: {failureCount}");
|
|
Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
|
|
|
|
return failureCount == 0 ? 0 : 1;
|
|
}
|
|
|
|
public static async Task<int> RunCutDebugSweepAsync(string[] args)
|
|
{
|
|
var options = CutDebugSweepOptions.Parse(args);
|
|
Directory.CreateDirectory(options.OutputPath);
|
|
|
|
Console.WriteLine("Karisma cut-debug sweep starting.");
|
|
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
|
Console.WriteLine($"- Mode: {options.Mode}");
|
|
Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
|
|
Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
|
|
Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
|
|
Console.WriteLine($"- Recommendations Loaded: {CutDebugRecommendationCatalog.Count}");
|
|
|
|
var pgmWindow = TryFindPgmWindow();
|
|
if (pgmWindow is null)
|
|
{
|
|
Console.WriteLine("PGM window was not found. Open the PGM window first and rerun.");
|
|
return 1;
|
|
}
|
|
|
|
var logService = new LogService();
|
|
var cutDebugStateStore = new CutDebugStateStore();
|
|
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
|
|
{
|
|
Console.WriteLine("Karisma adapter is not available. Sweep cannot continue.");
|
|
return 1;
|
|
}
|
|
|
|
var station = CreateValidationStation(options.StationLogoPath);
|
|
var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
|
|
var cutItems = new FormatCatalogService().GetAll()
|
|
.Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
|
|
.SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
|
|
.ToList();
|
|
|
|
cutItems = ApplyTemplateFilter(cutItems, options.Filter);
|
|
cutItems = ApplyCutFilter(cutItems, options.CutName);
|
|
if (options.ExcludeHistoricalTurnout)
|
|
{
|
|
cutItems = cutItems
|
|
.Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
|
|
.ToList();
|
|
}
|
|
|
|
if (options.Limit is int limit && limit > 0)
|
|
{
|
|
cutItems = cutItems.Take(limit).ToList();
|
|
}
|
|
|
|
if (cutItems.Count == 0)
|
|
{
|
|
Console.WriteLine("No cuts matched the requested filter.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"- Cuts: {cutItems.Count}");
|
|
Console.WriteLine();
|
|
|
|
var results = new List<CutDebugSweepResult>();
|
|
var replacementAssets = EnsureCutDebugReplacementAssets(options.OutputPath);
|
|
|
|
try
|
|
{
|
|
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
for (var index = 0; index < cutItems.Count; index++)
|
|
{
|
|
var item = cutItems[index];
|
|
Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");
|
|
|
|
var result = new CutDebugSweepResult
|
|
{
|
|
Index = index + 1,
|
|
TemplateId = item.Template.Id,
|
|
TemplateName = item.Template.Name,
|
|
CutName = item.Cut.Name,
|
|
Channel = item.Template.RecommendedChannel.ToString()
|
|
};
|
|
CutDebugTemplateState? templateState = null;
|
|
|
|
try
|
|
{
|
|
var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
|
|
result.ScenePath = resolvedScene.Path;
|
|
|
|
var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
|
|
templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
|
|
templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));
|
|
if (CutDebugRecommendationCatalog.TryGetRecommendation(item.Template.Id, out var recommendation))
|
|
{
|
|
Console.WriteLine($" preferred: {recommendation.Key} ({recommendation.Kind})");
|
|
}
|
|
|
|
var sweepItems = OrderSweepItems(item.Template.Id, templateState.Items, sceneVariables)
|
|
.Where(debugItem => options.IncludeItem(debugItem.Key, debugItem.Kind))
|
|
.ToList();
|
|
var hasExplicitItemFilter = !string.IsNullOrWhiteSpace(options.KeyFilter) ||
|
|
!string.IsNullOrWhiteSpace(options.KindFilter);
|
|
var targetChangedItems = 0;
|
|
|
|
if (options.MaxItems is int maxItems && maxItems > 0)
|
|
{
|
|
if (hasExplicitItemFilter)
|
|
{
|
|
sweepItems = sweepItems.Take(maxItems).ToList();
|
|
}
|
|
else
|
|
{
|
|
targetChangedItems = maxItems;
|
|
}
|
|
}
|
|
|
|
result.ItemCount = sweepItems.Count;
|
|
|
|
var settings = cutDebugStateStore.Get(item.Template.RecommendedChannel);
|
|
EnableAllDebugCategories(settings);
|
|
templateState.ClearOverrides();
|
|
SetAllItemsEnabled(templateState.Items, true);
|
|
|
|
var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
|
|
var snapshot = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
|
|
|
|
var baselineAPath = Path.Combine(
|
|
options.OutputPath,
|
|
$"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_A.png");
|
|
var baselineBPath = Path.Combine(
|
|
options.OutputPath,
|
|
$"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_B.png");
|
|
|
|
var baselineA = await CaptureSweepImageAsync(
|
|
adapter,
|
|
pgmWindow.Value,
|
|
item,
|
|
snapshot,
|
|
station,
|
|
options,
|
|
baselineAPath).ConfigureAwait(false);
|
|
|
|
var baselineB = await CaptureSweepImageAsync(
|
|
adapter,
|
|
pgmWindow.Value,
|
|
item,
|
|
snapshot,
|
|
station,
|
|
options,
|
|
baselineBPath).ConfigureAwait(false);
|
|
|
|
result.BaselineAPath = baselineA.Path;
|
|
result.BaselineBPath = baselineB.Path;
|
|
result.BaselineHash = baselineB.Hash;
|
|
|
|
var baselineDiffPath = Path.Combine(
|
|
options.OutputPath,
|
|
$"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_diff.png");
|
|
var baselineDiff = CompareImages(baselineA.Path, baselineB.Path, baselineDiffPath);
|
|
result.NoiseDiffPath = baselineDiffPath;
|
|
result.NoiseChangedPixels = baselineDiff.ChangedPixels;
|
|
result.SignificanceThresholdPixels = Math.Max(250, baselineDiff.ChangedPixels * 2);
|
|
|
|
var changedItemCount = 0;
|
|
foreach (var sweepItem in sweepItems)
|
|
{
|
|
templateState.ClearOverrides();
|
|
SetAllItemsEnabled(templateState.Items, true);
|
|
ConfigureSweepItem(templateState, sweepItem, options.Mode, replacementAssets);
|
|
|
|
var caseToken = $"{sweepItem.Key}_{sweepItem.Kind}_{options.Mode}";
|
|
var casePath = Path.Combine(
|
|
options.OutputPath,
|
|
$"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}.png");
|
|
var diffPath = Path.Combine(
|
|
options.OutputPath,
|
|
$"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}_diff.png");
|
|
|
|
var logCountBefore = logService.Entries.Count;
|
|
var caseCapture = await CaptureSweepImageAsync(
|
|
adapter,
|
|
pgmWindow.Value,
|
|
item,
|
|
snapshot,
|
|
station,
|
|
options,
|
|
casePath).ConfigureAwait(false);
|
|
var logPreview = CaptureRecentLogs(logService, logCountBefore, 30);
|
|
|
|
var diff = CompareImages(baselineB.Path, caseCapture.Path, diffPath);
|
|
var itemResult = new CutDebugItemSweepResult
|
|
{
|
|
Key = sweepItem.Key,
|
|
Kind = sweepItem.Kind.ToString(),
|
|
GroupLabel = sweepItem.GroupLabel,
|
|
CapturePath = caseCapture.Path,
|
|
DiffPath = diffPath,
|
|
Hash = caseCapture.Hash,
|
|
ChangedPixels = diff.ChangedPixels,
|
|
ChangeRatio = diff.ChangeRatio,
|
|
AverageChannelDelta = diff.AverageChannelDelta,
|
|
SignificantChange = diff.ChangedPixels > result.SignificanceThresholdPixels,
|
|
BoundingBox = diff.BoundingBox,
|
|
LogPreview = logPreview,
|
|
Detail = diff.ChangedPixels == 0
|
|
? $"No visual change ({options.Mode})"
|
|
: $"mode={options.Mode}, pixels={diff.ChangedPixels}, ratio={diff.ChangeRatio:P4}, bbox={diff.BoundingBox ?? "(none)"}"
|
|
};
|
|
result.Items.Add(itemResult);
|
|
|
|
if (itemResult.ChangedPixels > 0)
|
|
{
|
|
changedItemCount++;
|
|
if (targetChangedItems > 0 && changedItemCount >= targetChangedItems)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
result.Success = true;
|
|
result.ItemCount = result.Items.Count;
|
|
result.Detail = targetChangedItems > 0
|
|
? $"items={result.ItemCount}, targetChanged={targetChangedItems}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}"
|
|
: $"items={result.ItemCount}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Detail = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
templateState?.ClearOverrides();
|
|
|
|
try
|
|
{
|
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
|
results.Add(result);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
if (adapter is IDisposable disposable)
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
}
|
|
|
|
WriteCutDebugSweepReports(options, results);
|
|
|
|
var totalItems = results.Sum(result => result.Items.Count);
|
|
var changedItems = results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0));
|
|
var significantItems = results.Sum(result => result.Items.Count(item => item.SignificantChange));
|
|
var failures = results.Count(result => !result.Success);
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("Summary");
|
|
Console.WriteLine($"- Cuts: {results.Count}");
|
|
Console.WriteLine($"- Swept Items: {totalItems}");
|
|
Console.WriteLine($"- Changed Items: {changedItems}");
|
|
Console.WriteLine($"- Significant Items: {significantItems}");
|
|
Console.WriteLine($"- Failures: {failures}");
|
|
Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
|
|
|
|
return failures == 0 ? 0 : 1;
|
|
}
|
|
|
|
public static int RunCutDebugCoverageReport(string[] args)
|
|
{
|
|
var options = CutDebugCoverageOptions.Parse(args);
|
|
Directory.CreateDirectory(options.OutputPath);
|
|
|
|
Console.WriteLine("Karisma cut-debug coverage report starting.");
|
|
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
|
Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
|
|
Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
|
|
Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
|
|
|
|
var logService = new LogService();
|
|
var cutDebugStateStore = new CutDebugStateStore();
|
|
var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
|
|
var cutItems = new FormatCatalogService().GetAll()
|
|
.Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
|
|
.SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
|
|
.ToList();
|
|
|
|
cutItems = ApplyTemplateFilter(cutItems, options.Filter);
|
|
cutItems = ApplyCutFilter(cutItems, options.CutName);
|
|
|
|
if (options.ExcludeHistoricalTurnout)
|
|
{
|
|
cutItems = cutItems
|
|
.Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
|
|
.ToList();
|
|
}
|
|
|
|
if (options.Limit is int limit && limit > 0)
|
|
{
|
|
cutItems = cutItems.Take(limit).ToList();
|
|
}
|
|
|
|
if (cutItems.Count == 0)
|
|
{
|
|
Console.WriteLine("No cuts matched the requested filter.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"- Cuts: {cutItems.Count}");
|
|
Console.WriteLine();
|
|
|
|
var results = new List<CutDebugCoverageResult>();
|
|
|
|
for (var index = 0; index < cutItems.Count; index++)
|
|
{
|
|
var item = cutItems[index];
|
|
var result = new CutDebugCoverageResult
|
|
{
|
|
Index = index + 1,
|
|
TemplateId = item.Template.Id,
|
|
TemplateName = item.Template.Name,
|
|
CutName = item.Cut.Name,
|
|
Channel = item.Template.RecommendedChannel.ToString(),
|
|
ExcludedHistoricalTurnout = options.ExcludeHistoricalTurnout && IsHistoricalTurnoutTemplate(item.Template.Name)
|
|
};
|
|
|
|
Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");
|
|
|
|
try
|
|
{
|
|
var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
|
|
var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
|
|
var templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
|
|
templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));
|
|
|
|
result.ScenePath = resolvedScene.Path;
|
|
result.SceneVariableCount = sceneVariables.Count;
|
|
result.ItemCount = templateState.Items.Count;
|
|
result.TextItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.TextValue);
|
|
result.ImageItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.ImageValue);
|
|
result.CounterItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Counter);
|
|
result.StyleItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.StyleColor);
|
|
result.VisibilityItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Visibility);
|
|
result.SampleKeys = string.Join(", ", templateState.Items.Take(12).Select(debugItem => $"{debugItem.Key}:{debugItem.Kind}"));
|
|
result.Success = true;
|
|
result.Detail = $"sceneVars={result.SceneVariableCount}, items={result.ItemCount}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Detail = ex.Message;
|
|
}
|
|
|
|
results.Add(result);
|
|
}
|
|
|
|
WriteCutDebugCoverageReports(options, results);
|
|
|
|
var successCount = results.Count(result => result.Success);
|
|
var zeroCoverageCount = results.Count(result => result.Success && result.ItemCount == 0);
|
|
var failureCount = results.Count(result => !result.Success);
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("Summary");
|
|
Console.WriteLine($"- Success: {successCount}/{results.Count}");
|
|
Console.WriteLine($"- Zero Coverage: {zeroCoverageCount}");
|
|
Console.WriteLine($"- Failures: {failureCount}");
|
|
Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
|
|
|
|
return failureCount == 0 ? 0 : 1;
|
|
}
|
|
|
|
private static async Task<SweepCaptureResult> CaptureSweepImageAsync(
|
|
ITornado3Adapter adapter,
|
|
PgmWindow pgmWindow,
|
|
LiveCutWorkItem item,
|
|
ElectionDataSnapshot snapshot,
|
|
BroadcastStationProfile station,
|
|
CutDebugSweepOptions options,
|
|
string outputPath)
|
|
{
|
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
|
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
|
|
|
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
|
await Task.Delay(ResolveSweepOnAirDelayMs(item.Template, options)).ConfigureAwait(false);
|
|
|
|
return new SweepCaptureResult(outputPath, CapturePgm(pgmWindow, outputPath, skipCapture: false));
|
|
}
|
|
|
|
private static void EnableAllDebugCategories(CutDebugSettings settings)
|
|
{
|
|
settings.IsEnabled = true;
|
|
settings.ApplyTextValues = true;
|
|
settings.ApplyImageValues = true;
|
|
settings.ApplyVisibilityValues = true;
|
|
settings.ApplyVoteRateTextValues = true;
|
|
settings.ApplyVoteRateCounterValues = true;
|
|
settings.ApplyPartyBarStyleColors = true;
|
|
settings.ApplyPartyPlateStyleColors = true;
|
|
settings.ApplyVoteRateStyleColors = true;
|
|
}
|
|
|
|
private static void SetAllItemsEnabled(IEnumerable<CutDebugItemState> items, bool isEnabled)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
item.IsEnabled = isEnabled;
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<CutDebugItemState> OrderSweepItems(
|
|
string templateId,
|
|
IEnumerable<CutDebugItemState> items,
|
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
|
{
|
|
var sceneVariableKeys = sceneVariables.Keys
|
|
.Select(CutDebugTemplateState.NormalizeKey)
|
|
.Where(key => !string.IsNullOrWhiteSpace(key))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var hasRecommendation = CutDebugRecommendationCatalog.TryGetRecommendation(templateId, out var recommendation);
|
|
|
|
return items
|
|
.OrderBy(item => hasRecommendation ? !MatchesRecommendation(item, recommendation) : false)
|
|
.ThenBy(item => !sceneVariableKeys.Contains(CutDebugTemplateState.NormalizeKey(item.Key)))
|
|
.ThenBy(item => GetSweepKeyPriority(item.Key))
|
|
.ThenBy(item => GetSweepKindPriority(item.Kind))
|
|
.ThenBy(item => ResolveDebugItemIndex(item.Key) ?? int.MaxValue)
|
|
.ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
private static bool MatchesRecommendation(CutDebugItemState item, CutDebugRecommendation recommendation)
|
|
{
|
|
return item.Kind == recommendation.Kind &&
|
|
string.Equals(
|
|
CutDebugTemplateState.NormalizeKey(item.Key),
|
|
CutDebugTemplateState.NormalizeKey(recommendation.Key),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static void ConfigureSweepItem(
|
|
CutDebugTemplateState templateState,
|
|
CutDebugItemState sweepItem,
|
|
CutDebugSweepMode mode,
|
|
CutDebugReplacementAssets replacementAssets)
|
|
{
|
|
if (mode == CutDebugSweepMode.Disable)
|
|
{
|
|
sweepItem.IsEnabled = false;
|
|
return;
|
|
}
|
|
|
|
var itemIndex = ResolveDebugItemIndex(sweepItem.Key);
|
|
switch (sweepItem.Kind)
|
|
{
|
|
case CutDebugItemKind.TextValue:
|
|
templateState.SetOverride(
|
|
sweepItem.Key,
|
|
sweepItem.Kind,
|
|
CutDebugOverride.ForString(itemIndex.HasValue ? $"DBG{itemIndex:00}" : "DEBUG"));
|
|
break;
|
|
case CutDebugItemKind.ImageValue:
|
|
templateState.SetOverride(
|
|
sweepItem.Key,
|
|
sweepItem.Kind,
|
|
CutDebugOverride.ForString(itemIndex.GetValueOrDefault(1) % 2 == 0
|
|
? replacementAssets.CyanPath
|
|
: replacementAssets.MagentaPath));
|
|
break;
|
|
case CutDebugItemKind.Counter:
|
|
templateState.SetOverride(
|
|
sweepItem.Key,
|
|
sweepItem.Kind,
|
|
CutDebugOverride.ForNumber(itemIndex.GetValueOrDefault(1) % 2 == 0 ? 12.3 : 87.6));
|
|
break;
|
|
case CutDebugItemKind.StyleColor:
|
|
templateState.SetOverride(
|
|
sweepItem.Key,
|
|
sweepItem.Kind,
|
|
itemIndex.GetValueOrDefault(1) % 2 == 0
|
|
? CutDebugOverride.ForColor(0, 255, 255)
|
|
: CutDebugOverride.ForColor(255, 0, 255));
|
|
break;
|
|
case CutDebugItemKind.Visibility:
|
|
templateState.SetOverride(
|
|
sweepItem.Key,
|
|
sweepItem.Kind,
|
|
CutDebugOverride.ForVisibility(false));
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static CutDebugReplacementAssets EnsureCutDebugReplacementAssets(string outputPath)
|
|
{
|
|
var assetsPath = Path.Combine(outputPath, "_debug_assets");
|
|
Directory.CreateDirectory(assetsPath);
|
|
|
|
var magentaPath = Path.Combine(assetsPath, "debug_magenta.png");
|
|
var cyanPath = Path.Combine(assetsPath, "debug_cyan.png");
|
|
WriteDebugReplacementAsset(magentaPath, Color.FromArgb(255, 255, 0, 255), Color.FromArgb(255, 255, 255, 0));
|
|
WriteDebugReplacementAsset(cyanPath, Color.FromArgb(255, 0, 255, 255), Color.FromArgb(255, 0, 0, 0));
|
|
return new CutDebugReplacementAssets(magentaPath, cyanPath);
|
|
}
|
|
|
|
private static void WriteDebugReplacementAsset(string path, Color background, Color accent)
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
return;
|
|
}
|
|
|
|
using var bitmap = new Bitmap(384, 384, PixelFormat.Format32bppArgb);
|
|
using var graphics = Graphics.FromImage(bitmap);
|
|
graphics.Clear(background);
|
|
|
|
using var borderPen = new Pen(accent, 24);
|
|
graphics.DrawRectangle(borderPen, 12, 12, bitmap.Width - 24, bitmap.Height - 24);
|
|
graphics.DrawLine(borderPen, 24, 24, bitmap.Width - 24, bitmap.Height - 24);
|
|
graphics.DrawLine(borderPen, bitmap.Width - 24, 24, 24, bitmap.Height - 24);
|
|
|
|
using var font = new Font("Arial", 72, FontStyle.Bold, GraphicsUnit.Pixel);
|
|
using var brush = new SolidBrush(accent);
|
|
var text = "DBG";
|
|
var size = graphics.MeasureString(text, font);
|
|
graphics.DrawString(
|
|
text,
|
|
font,
|
|
brush,
|
|
(bitmap.Width - size.Width) / 2,
|
|
(bitmap.Height - size.Height) / 2);
|
|
|
|
bitmap.Save(path, ImageFormat.Png);
|
|
}
|
|
|
|
private static int? ResolveDebugItemIndex(string key)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key) || key.Length < 2)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var suffix = key[^2..];
|
|
return int.TryParse(suffix, out var parsed) && parsed > 0
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private static int GetSweepKindPriority(CutDebugItemKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
CutDebugItemKind.ImageValue => 0,
|
|
CutDebugItemKind.Counter => 1,
|
|
CutDebugItemKind.TextValue => 2,
|
|
CutDebugItemKind.StyleColor => 3,
|
|
CutDebugItemKind.Visibility => 4,
|
|
_ => 9
|
|
};
|
|
}
|
|
|
|
private static int GetSweepKeyPriority(string key)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
{
|
|
return 99;
|
|
}
|
|
|
|
var normalizedKey = CutDebugTemplateState.NormalizeKey(key);
|
|
return normalizedKey switch
|
|
{
|
|
var value when value.StartsWith("후보사진", StringComparison.OrdinalIgnoreCase) => 0,
|
|
var value when value.StartsWith("후보명", StringComparison.OrdinalIgnoreCase) => 1,
|
|
var value when value.StartsWith("투표율", StringComparison.OrdinalIgnoreCase) => 2,
|
|
var value when value.StartsWith("전국투표율", StringComparison.OrdinalIgnoreCase) => 2,
|
|
var value when value.StartsWith("득표율", StringComparison.OrdinalIgnoreCase) => 2,
|
|
var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
|
|
var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
|
|
var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
|
|
var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
|
|
var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
|
|
var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
|
|
var value when value.StartsWith("정당바", StringComparison.OrdinalIgnoreCase) => 8,
|
|
var value when value.StartsWith("정당판", StringComparison.OrdinalIgnoreCase) => 8,
|
|
var value when value.StartsWith("득표수바", StringComparison.OrdinalIgnoreCase) => 8,
|
|
var value when value.StartsWith("기호", StringComparison.OrdinalIgnoreCase) => 9,
|
|
var value when value.StartsWith("순위", StringComparison.OrdinalIgnoreCase) => 9,
|
|
var value when value.StartsWith("기준시", StringComparison.OrdinalIgnoreCase) => 10,
|
|
_ => 20
|
|
};
|
|
}
|
|
|
|
private static int ResolveSweepOnAirDelayMs(FormatTemplateDefinition template, CutDebugSweepOptions options)
|
|
{
|
|
return ResolveTemplateOnAirDelayMs(template, options.OnAirDelayMs);
|
|
}
|
|
|
|
private static int ResolveTemplateOnAirDelayMs(FormatTemplateDefinition template, int configuredDelay)
|
|
{
|
|
var delay = configuredDelay;
|
|
if (template.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal))
|
|
{
|
|
return Math.Max(delay, 8000);
|
|
}
|
|
|
|
return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
|
|
template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase)
|
|
? Math.Max(delay, 3500)
|
|
: delay;
|
|
}
|
|
|
|
private static string? CaptureRecentLogs(LogService logService, int previousCount, int maxLines)
|
|
{
|
|
var addedCount = Math.Max(0, logService.Entries.Count - previousCount);
|
|
if (addedCount == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return string.Join(
|
|
" || ",
|
|
logService.Entries
|
|
.Take(Math.Min(addedCount, maxLines))
|
|
.Select(entry => entry.Message));
|
|
}
|
|
|
|
private static BroadcastStationProfile CreateValidationStation(string stationLogoPath)
|
|
{
|
|
return new BroadcastStationProfile
|
|
{
|
|
Id = "AUTO",
|
|
Name = "AUTO",
|
|
LogoAssetPath = stationLogoPath,
|
|
RegionFilters = ["서울", "인천", "충남"]
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugDescriptors(
|
|
FormatTemplateDefinition template,
|
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
|
{
|
|
var descriptors = new List<CutDebugItemDescriptor>();
|
|
|
|
foreach (var variable in sceneVariables.Values.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var normalizedKey = CutDebugTemplateState.NormalizeKey(variable.Name);
|
|
if (string.IsNullOrWhiteSpace(normalizedKey))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var primaryKind = variable.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource
|
|
? CutDebugItemKind.ImageValue
|
|
: CutDebugItemKind.TextValue;
|
|
descriptors.Add(new CutDebugItemDescriptor(normalizedKey, primaryKind, ResolveDebugGroupLabel(normalizedKey, primaryKind)));
|
|
|
|
if (variable.Kind == KarismaSceneVariableKind.Counter || IsVoteRateDebugKey(normalizedKey))
|
|
{
|
|
descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Counter, "counter"));
|
|
}
|
|
|
|
if (IsStyleDebugKey(normalizedKey))
|
|
{
|
|
descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.StyleColor, "style"));
|
|
}
|
|
|
|
if (IsVisibilityDebugKey(normalizedKey))
|
|
{
|
|
descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Visibility, "visibility"));
|
|
}
|
|
}
|
|
|
|
descriptors.AddRange(
|
|
BuildHeuristicCutDebugDescriptors(template)
|
|
.Where(descriptor => ShouldIncludeHeuristicDescriptor(template, sceneVariables, descriptor)));
|
|
|
|
return descriptors;
|
|
}
|
|
|
|
private static bool ShouldExcludeHistoricalTurnoutGraph(
|
|
FormatTemplateDefinition template,
|
|
string normalizedKey)
|
|
{
|
|
return IsHistoricalTurnoutTemplate(template.Name) &&
|
|
string.Equals(normalizedKey, "차트01", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool ShouldIncludeHeuristicDescriptor(
|
|
FormatTemplateDefinition template,
|
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
|
CutDebugItemDescriptor descriptor)
|
|
{
|
|
var normalizedKey = CutDebugTemplateState.NormalizeKey(descriptor.Key);
|
|
if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(normalizedKey, "전국투표율01", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return sceneVariables.Keys.Any(key =>
|
|
string.Equals(
|
|
CutDebugTemplateState.NormalizeKey(key),
|
|
normalizedKey,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static IEnumerable<CutDebugItemDescriptor> BuildHeuristicCutDebugDescriptors(FormatTemplateDefinition template)
|
|
{
|
|
var slotCount = ResolveDebugSlotCount(template);
|
|
|
|
foreach (var prefix in new[] { "시도명", "개표율", "투표율", "전국투표율", "기준시", "유권자수", "투표자수" })
|
|
{
|
|
yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
|
|
}
|
|
|
|
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
|
|
{
|
|
for (var slot = 1; slot <= slotCount; slot++)
|
|
{
|
|
yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.TextValue, "text");
|
|
}
|
|
}
|
|
|
|
foreach (var prefix in new[] { "유확당", "후보사진", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당심볼", "그룹", "바" })
|
|
{
|
|
for (var slot = 1; slot <= slotCount; slot++)
|
|
{
|
|
yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.ImageValue, "image");
|
|
}
|
|
}
|
|
|
|
foreach (var slot in Enumerable.Range(1, slotCount))
|
|
{
|
|
yield return new CutDebugItemDescriptor($"득표율{slot:00}", CutDebugItemKind.Counter, "counter");
|
|
}
|
|
|
|
yield return new CutDebugItemDescriptor("전국투표율01", CutDebugItemKind.Counter, "counter");
|
|
|
|
foreach (var prefix in new[] { "기호", "기호텍스트", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당명", "득표율" })
|
|
{
|
|
for (var slot = 1; slot <= slotCount; slot++)
|
|
{
|
|
yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.StyleColor, "style");
|
|
}
|
|
}
|
|
|
|
foreach (var prefix in new[] { "유확당", "그룹" })
|
|
{
|
|
for (var slot = 1; slot <= slotCount; slot++)
|
|
{
|
|
yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.Visibility, "visibility");
|
|
}
|
|
}
|
|
|
|
foreach (var slot in Enumerable.Range(1, 3))
|
|
{
|
|
yield return new CutDebugItemDescriptor($"공약그룹{slot:00}", CutDebugItemKind.Visibility, "visibility");
|
|
}
|
|
}
|
|
|
|
private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
|
|
{
|
|
var source = $"{template.Name} {template.Id}";
|
|
var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
|
|
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
|
|
{
|
|
return Math.Max(1, topRankSlots);
|
|
}
|
|
|
|
var peopleMatch = System.Text.RegularExpressions.Regex.Match(source, @"(\d+)인");
|
|
if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlots))
|
|
{
|
|
return Math.Max(1, peopleSlots);
|
|
}
|
|
|
|
return 2;
|
|
}
|
|
|
|
private static string ResolveDebugGroupLabel(string key, CutDebugItemKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
CutDebugItemKind.TextValue when key.StartsWith("공약", StringComparison.Ordinal) => "promise",
|
|
CutDebugItemKind.TextValue => "text",
|
|
CutDebugItemKind.ImageValue => "image",
|
|
CutDebugItemKind.Counter => "counter",
|
|
CutDebugItemKind.StyleColor => "style",
|
|
CutDebugItemKind.Visibility => "visibility",
|
|
_ => "other"
|
|
};
|
|
}
|
|
|
|
private static bool IsVoteRateDebugKey(string key)
|
|
{
|
|
return key.StartsWith("득표율", StringComparison.Ordinal) ||
|
|
key.StartsWith("투표율", StringComparison.Ordinal) ||
|
|
key.StartsWith("전국투표율", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsHistoricalTurnoutTemplate(string templateName)
|
|
{
|
|
return templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsStyleDebugKey(string key)
|
|
{
|
|
return key.StartsWith("기호", StringComparison.Ordinal) ||
|
|
key.StartsWith("득표수바", StringComparison.Ordinal) ||
|
|
key.StartsWith("정당바", StringComparison.Ordinal) ||
|
|
key.StartsWith("정당판", StringComparison.Ordinal) ||
|
|
key.StartsWith("정당원", StringComparison.Ordinal) ||
|
|
key.StartsWith("정당색", StringComparison.Ordinal) ||
|
|
key.StartsWith("정당명", StringComparison.Ordinal) ||
|
|
key.StartsWith("득표율", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsVisibilityDebugKey(string key)
|
|
{
|
|
return key.StartsWith("유확당", StringComparison.Ordinal) ||
|
|
key.StartsWith("그룹", StringComparison.Ordinal) ||
|
|
key.StartsWith("공약그룹", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static ImageDiffResult CompareImages(string baselinePath, string candidatePath, string diffPath)
|
|
{
|
|
using var baselineOriginal = new Bitmap(baselinePath);
|
|
using var candidateOriginal = new Bitmap(candidatePath);
|
|
using var baseline = EnsureArgbBitmap(baselineOriginal);
|
|
using var candidate = EnsureArgbBitmap(candidateOriginal);
|
|
|
|
if (baseline.Width != candidate.Width || baseline.Height != candidate.Height)
|
|
{
|
|
throw new InvalidOperationException("Baseline and candidate captures have different dimensions.");
|
|
}
|
|
|
|
var width = baseline.Width;
|
|
var height = baseline.Height;
|
|
using var diffBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
|
|
|
var baselineData = baseline.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
|
var candidateData = candidate.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
|
var diffData = diffBitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
|
|
|
|
try
|
|
{
|
|
var baselineStride = Math.Abs(baselineData.Stride);
|
|
var candidateStride = Math.Abs(candidateData.Stride);
|
|
var diffStride = Math.Abs(diffData.Stride);
|
|
var baselineBytes = new byte[baselineStride * height];
|
|
var candidateBytes = new byte[candidateStride * height];
|
|
var diffBytes = new byte[diffStride * height];
|
|
|
|
Marshal.Copy(baselineData.Scan0, baselineBytes, 0, baselineBytes.Length);
|
|
Marshal.Copy(candidateData.Scan0, candidateBytes, 0, candidateBytes.Length);
|
|
|
|
var changedPixels = 0;
|
|
long totalDelta = 0;
|
|
int? minX = null;
|
|
int? minY = null;
|
|
int? maxX = null;
|
|
int? maxY = null;
|
|
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
var baselineRowOffset = y * baselineStride;
|
|
var candidateRowOffset = y * candidateStride;
|
|
var diffRowOffset = y * diffStride;
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
var baselineOffset = baselineRowOffset + (x * 4);
|
|
var candidateOffset = candidateRowOffset + (x * 4);
|
|
var diffOffset = diffRowOffset + (x * 4);
|
|
var blueDelta = Math.Abs(baselineBytes[baselineOffset] - candidateBytes[candidateOffset]);
|
|
var greenDelta = Math.Abs(baselineBytes[baselineOffset + 1] - candidateBytes[candidateOffset + 1]);
|
|
var redDelta = Math.Abs(baselineBytes[baselineOffset + 2] - candidateBytes[candidateOffset + 2]);
|
|
var alphaDelta = Math.Abs(baselineBytes[baselineOffset + 3] - candidateBytes[candidateOffset + 3]);
|
|
var delta = blueDelta + greenDelta + redDelta + alphaDelta;
|
|
|
|
if (delta > 24)
|
|
{
|
|
changedPixels++;
|
|
totalDelta += delta;
|
|
minX = !minX.HasValue || x < minX.Value ? x : minX.Value;
|
|
minY = !minY.HasValue || y < minY.Value ? y : minY.Value;
|
|
maxX = !maxX.HasValue || x > maxX.Value ? x : maxX.Value;
|
|
maxY = !maxY.HasValue || y > maxY.Value ? y : maxY.Value;
|
|
|
|
diffBytes[diffOffset] = 0;
|
|
diffBytes[diffOffset + 1] = (byte)Math.Min(255, delta);
|
|
diffBytes[diffOffset + 2] = 255;
|
|
diffBytes[diffOffset + 3] = 255;
|
|
}
|
|
else
|
|
{
|
|
diffBytes[diffOffset] = 0;
|
|
diffBytes[diffOffset + 1] = 0;
|
|
diffBytes[diffOffset + 2] = 0;
|
|
diffBytes[diffOffset + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
Marshal.Copy(diffBytes, 0, diffData.Scan0, diffBytes.Length);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(diffPath)!);
|
|
diffBitmap.Save(diffPath, ImageFormat.Png);
|
|
|
|
var totalPixels = Math.Max(1, width * height);
|
|
var boundingBox = minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue
|
|
? $"{minX.Value},{minY.Value} - {maxX.Value},{maxY.Value}"
|
|
: null;
|
|
|
|
return new ImageDiffResult(
|
|
changedPixels,
|
|
changedPixels / (double)totalPixels,
|
|
changedPixels == 0 ? 0 : totalDelta / (double)changedPixels,
|
|
boundingBox);
|
|
}
|
|
finally
|
|
{
|
|
baseline.UnlockBits(baselineData);
|
|
candidate.UnlockBits(candidateData);
|
|
diffBitmap.UnlockBits(diffData);
|
|
}
|
|
}
|
|
|
|
private static Bitmap EnsureArgbBitmap(Bitmap source)
|
|
{
|
|
if (source.PixelFormat == PixelFormat.Format32bppArgb)
|
|
{
|
|
return (Bitmap)source.Clone();
|
|
}
|
|
|
|
var converted = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
|
using var graphics = Graphics.FromImage(converted);
|
|
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
|
return converted;
|
|
}
|
|
|
|
private static void WriteCutDebugSweepReports(CutDebugSweepOptions options, IReadOnlyList<CutDebugSweepResult> results)
|
|
{
|
|
var jsonPath = Path.Combine(options.OutputPath, "results.json");
|
|
var csvPath = Path.Combine(options.OutputPath, "items.csv");
|
|
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
|
|
|
File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
|
|
|
|
var csv = new StringBuilder();
|
|
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Key,Kind,GroupLabel,ChangedPixels,ChangeRatio,AverageChannelDelta,SignificantChange,BoundingBox,CapturePath,DiffPath,LogPreview,Detail");
|
|
foreach (var result in results)
|
|
{
|
|
foreach (var item in result.Items)
|
|
{
|
|
csv.AppendLine(string.Join(",",
|
|
Csv(result.Index.ToString()),
|
|
Csv(result.TemplateId),
|
|
Csv(result.TemplateName),
|
|
Csv(result.CutName),
|
|
Csv(result.Channel),
|
|
Csv(item.Key),
|
|
Csv(item.Kind),
|
|
Csv(item.GroupLabel),
|
|
Csv(item.ChangedPixels.ToString()),
|
|
Csv(item.ChangeRatio.ToString("0.000000")),
|
|
Csv(item.AverageChannelDelta.ToString("0.00")),
|
|
Csv(item.SignificantChange.ToString()),
|
|
Csv(item.BoundingBox ?? string.Empty),
|
|
Csv(item.CapturePath ?? string.Empty),
|
|
Csv(item.DiffPath ?? string.Empty),
|
|
Csv(item.LogPreview ?? string.Empty),
|
|
Csv(item.Detail ?? string.Empty)));
|
|
}
|
|
}
|
|
|
|
File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
|
|
|
|
var summary = new StringBuilder();
|
|
summary.AppendLine("# Cut Debug Sweep");
|
|
summary.AppendLine();
|
|
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
|
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
|
summary.AppendLine($"- Output: {options.OutputPath}");
|
|
summary.AppendLine($"- Mode: {options.Mode}");
|
|
summary.AppendLine($"- Cuts: {results.Count}");
|
|
summary.AppendLine($"- Total Items: {results.Sum(result => result.Items.Count)}");
|
|
summary.AppendLine($"- Changed Items: {results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0))}");
|
|
summary.AppendLine($"- Significant Items: {results.Sum(result => result.Items.Count(item => item.SignificantChange))}");
|
|
summary.AppendLine();
|
|
|
|
foreach (var result in results)
|
|
{
|
|
summary.AppendLine($"## `{result.TemplateId}` / `{result.CutName}`");
|
|
summary.AppendLine();
|
|
summary.AppendLine($"- Success: {result.Success}");
|
|
summary.AppendLine($"- Scene: `{result.ScenePath}`");
|
|
summary.AppendLine($"- Items: {result.ItemCount}");
|
|
summary.AppendLine($"- Changed Items: {result.Items.Count(item => item.ChangedPixels > 0)}");
|
|
summary.AppendLine($"- Baseline Noise Pixels: {result.NoiseChangedPixels}");
|
|
summary.AppendLine($"- Significant Threshold Pixels: {result.SignificanceThresholdPixels}");
|
|
summary.AppendLine($"- Detail: {result.Detail}");
|
|
summary.AppendLine();
|
|
|
|
var topItems = result.Items
|
|
.OrderByDescending(item => item.SignificantChange)
|
|
.ThenByDescending(item => item.ChangedPixels)
|
|
.Take(20)
|
|
.ToList();
|
|
|
|
if (topItems.Count == 0)
|
|
{
|
|
summary.AppendLine("- No item results.");
|
|
summary.AppendLine();
|
|
continue;
|
|
}
|
|
|
|
summary.AppendLine("| Key | Kind | Group | Pixels | Ratio | Significant | Bounding Box |");
|
|
summary.AppendLine("| --- | --- | --- | ---: | ---: | --- | --- |");
|
|
foreach (var item in topItems)
|
|
{
|
|
summary.AppendLine($"| `{item.Key}` | `{item.Kind}` | `{item.GroupLabel}` | {item.ChangedPixels} | {item.ChangeRatio:0.000000} | {(item.SignificantChange ? "yes" : "no")} | `{item.BoundingBox ?? string.Empty}` |");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
summary.AppendLine("## Files");
|
|
summary.AppendLine();
|
|
summary.AppendLine("- `results.json`");
|
|
summary.AppendLine("- `items.csv`");
|
|
summary.AppendLine("- `*_baseline_A.png`, `*_baseline_B.png`, `*_baseline_diff.png`");
|
|
summary.AppendLine("- `*_{Kind}_{Mode}.png`, `*_diff.png`");
|
|
|
|
File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
|
|
}
|
|
|
|
private static void WriteCutDebugCoverageReports(CutDebugCoverageOptions options, IReadOnlyList<CutDebugCoverageResult> results)
|
|
{
|
|
var jsonPath = Path.Combine(options.OutputPath, "results.json");
|
|
var csvPath = Path.Combine(options.OutputPath, "results.csv");
|
|
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
|
|
|
File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
|
|
|
|
var csv = new StringBuilder();
|
|
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Success,ScenePath,SceneVariableCount,ItemCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount,SampleKeys,Detail");
|
|
foreach (var result in results)
|
|
{
|
|
csv.AppendLine(string.Join(",",
|
|
Csv(result.Index.ToString()),
|
|
Csv(result.TemplateId),
|
|
Csv(result.TemplateName),
|
|
Csv(result.CutName),
|
|
Csv(result.Channel),
|
|
Csv(result.Success.ToString()),
|
|
Csv(result.ScenePath ?? string.Empty),
|
|
Csv(result.SceneVariableCount.ToString()),
|
|
Csv(result.ItemCount.ToString()),
|
|
Csv(result.TextItemCount.ToString()),
|
|
Csv(result.ImageItemCount.ToString()),
|
|
Csv(result.CounterItemCount.ToString()),
|
|
Csv(result.StyleItemCount.ToString()),
|
|
Csv(result.VisibilityItemCount.ToString()),
|
|
Csv(result.SampleKeys ?? string.Empty),
|
|
Csv(result.Detail ?? string.Empty)));
|
|
}
|
|
|
|
File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
|
|
|
|
var zeroCoverage = results
|
|
.Where(result => result.Success && result.ItemCount == 0)
|
|
.OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var failures = results
|
|
.Where(result => !result.Success)
|
|
.OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var strongestCoverage = results
|
|
.Where(result => result.Success)
|
|
.OrderByDescending(result => result.ItemCount)
|
|
.ThenBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
|
|
.Take(15)
|
|
.ToList();
|
|
|
|
var summary = new StringBuilder();
|
|
summary.AppendLine("# Cut Debug Coverage");
|
|
summary.AppendLine();
|
|
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
|
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
|
summary.AppendLine($"- Output: {options.OutputPath}");
|
|
summary.AppendLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
|
|
summary.AppendLine($"- Cuts: {results.Count}");
|
|
summary.AppendLine($"- Zero Coverage: {zeroCoverage.Count}");
|
|
summary.AppendLine($"- Failures: {failures.Count}");
|
|
summary.AppendLine();
|
|
|
|
if (zeroCoverage.Count > 0)
|
|
{
|
|
summary.AppendLine("## Zero Coverage");
|
|
summary.AppendLine();
|
|
foreach (var result in zeroCoverage)
|
|
{
|
|
summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
{
|
|
summary.AppendLine("## Failures");
|
|
summary.AppendLine();
|
|
foreach (var result in failures)
|
|
{
|
|
summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Detail}");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
if (strongestCoverage.Count > 0)
|
|
{
|
|
summary.AppendLine("## Top Coverage");
|
|
summary.AppendLine();
|
|
foreach (var result in strongestCoverage)
|
|
{
|
|
summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: items={result.ItemCount}, sceneVars={result.SceneVariableCount}");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
summary.AppendLine("## Files");
|
|
summary.AppendLine();
|
|
summary.AppendLine("- `results.csv`");
|
|
summary.AppendLine("- `results.json`");
|
|
|
|
File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
|
|
}
|
|
|
|
private static async Task OutAllAsync(ITornado3Adapter adapter)
|
|
{
|
|
foreach (var channel in new[]
|
|
{
|
|
BroadcastChannel.Normal,
|
|
BroadcastChannel.TopLeft,
|
|
BroadcastChannel.Bottom,
|
|
BroadcastChannel.VideoWall
|
|
})
|
|
{
|
|
try
|
|
{
|
|
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool ShouldUsePreElectionSnapshot(string templateName)
|
|
{
|
|
return templateName.Contains("투표율", StringComparison.Ordinal) ||
|
|
templateName.StartsWith("사전_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates)
|
|
{
|
|
var metadata = BuildScenarioMetadata(templateName, index, variant);
|
|
return new ElectionDataSnapshot
|
|
{
|
|
BroadcastPhase = preElection ? BroadcastPhase.PreElection : BroadcastPhase.Counting,
|
|
ElectionType = metadata.ElectionType,
|
|
DistrictName = metadata.DistrictName,
|
|
DistrictCode = metadata.DistrictCode,
|
|
RegionName = metadata.RegionName,
|
|
ElectionDistrictName = metadata.ElectionDistrictName,
|
|
Candidates = preElection ? Array.Empty<CandidateEntry>() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
|
|
TotalExpectedVotes = metadata.TotalExpectedVotes,
|
|
TurnoutVotes = metadata.TurnoutVotes,
|
|
CountedVotesFromApi = metadata.CountedVotes,
|
|
RemainingVotesFromApi = Math.Max(0, metadata.TurnoutVotes - metadata.CountedVotes),
|
|
CountedRateFromApi = metadata.CountedRate,
|
|
ReceivedAt = metadata.ReceivedAt,
|
|
HistoricalTurnoutHistory = CreateHistoricalTurnout(metadata, variant),
|
|
HistoricalWinnerHistory = CreateHistoricalWinners(variant),
|
|
TurnoutBoardSlots = CreateTurnoutBoardSlots(metadata, variant),
|
|
NationalTurnoutRateOverride = metadata.NationalTurnoutRate
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
|
|
{
|
|
var candidateNames = ResolveCandidateNames(templateName);
|
|
var parties = ResolveParties(candidateNames.Length);
|
|
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
|
|
var automaticJudgement = ResolveAutomaticJudgement(templateName);
|
|
var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
|
|
|
|
if (swapTopTwoCandidates && identityOrder.Length >= 2)
|
|
{
|
|
(identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
|
|
}
|
|
|
|
var candidates = new List<CandidateEntry>(candidateNames.Length);
|
|
for (var index = 0; index < candidateNames.Length; index++)
|
|
{
|
|
var identityIndex = identityOrder[index];
|
|
candidates.Add(new CandidateEntry
|
|
{
|
|
CandidateCode = (index + 1).ToString(),
|
|
Name = candidateNames[identityIndex],
|
|
Party = parties[identityIndex],
|
|
ColorParty = parties[identityIndex],
|
|
VoteRate = shares[index],
|
|
VoteCount = (int)Math.Round(metadata.CountedVotes * shares[index] / 100d, MidpointRounding.AwayFromZero),
|
|
HasImage = true,
|
|
ManualJudgement = CandidateJudgement.None,
|
|
AutomaticJudgement = index == 0 ? automaticJudgement : CandidateJudgement.None
|
|
});
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
|
|
{
|
|
// The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
|
|
// For automated debug captures, seed the text values so they follow that baked-in graph shape.
|
|
var seeds = string.Equals(metadata.ElectionType, "교육감", StringComparison.Ordinal)
|
|
? HistoricalTurnoutEducationSeeds
|
|
: HistoricalTurnoutLocalSeeds;
|
|
var entries = new List<PreElectionHistoricalTurnoutEntry>(seeds.Count);
|
|
var variantOffset = variant * 0.5;
|
|
|
|
for (var index = 0; index < seeds.Count; index++)
|
|
{
|
|
var seed = seeds[index];
|
|
var electors = Math.Max(1000, metadata.TotalExpectedVotes - 75000 + (index * 25000));
|
|
var rate = Math.Clamp(seed.TurnoutRate + variantOffset, 0.1, 99.9);
|
|
entries.Add(new PreElectionHistoricalTurnoutEntry
|
|
{
|
|
ElectionOrder = seed.ElectionOrder,
|
|
Year = seed.Year,
|
|
Electors = electors,
|
|
Votes = (int)Math.Round(electors * rate / 100d, MidpointRounding.AwayFromZero),
|
|
TurnoutRate = rate
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
private static IReadOnlyList<PreElectionHistoricalWinnerEntry> CreateHistoricalWinners(int variant)
|
|
{
|
|
return new[]
|
|
{
|
|
new PreElectionHistoricalWinnerEntry
|
|
{
|
|
ElectionOrder = 5,
|
|
Year = 2010,
|
|
Name = "김민수",
|
|
Party = "열린우리당",
|
|
ColorParty = "열린우리당",
|
|
Note = string.Empty
|
|
},
|
|
new PreElectionHistoricalWinnerEntry
|
|
{
|
|
ElectionOrder = 6,
|
|
Year = 2014,
|
|
Name = "박정우",
|
|
Party = "새누리당",
|
|
ColorParty = "새누리당",
|
|
Note = string.Empty
|
|
},
|
|
new PreElectionHistoricalWinnerEntry
|
|
{
|
|
ElectionOrder = 7,
|
|
Year = 2018,
|
|
Name = "이서연",
|
|
Party = "더불어민주당",
|
|
ColorParty = "더불어민주당",
|
|
Note = string.Empty
|
|
},
|
|
new PreElectionHistoricalWinnerEntry
|
|
{
|
|
ElectionOrder = 8,
|
|
Year = 2022,
|
|
Name = variant == 0 ? "최도윤" : "최서준",
|
|
Party = variant == 0 ? "국민의힘" : "더불어민주당",
|
|
ColorParty = variant == 0 ? "국민의힘" : "더불어민주당",
|
|
Note = variant == 0 ? "재선" : "정권교체"
|
|
}
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<TurnoutBoardSlotEntry> CreateTurnoutBoardSlots(ScenarioMetadata metadata, int variant)
|
|
{
|
|
return new[]
|
|
{
|
|
new TurnoutBoardSlotEntry(1, "전국", metadata.NationalTurnoutRate, true),
|
|
new TurnoutBoardSlotEntry(2, metadata.RegionName, 61.4 + (metadata.TemplateSeed % 5) + variant * 1.7),
|
|
new TurnoutBoardSlotEntry(3, "세종", 59.2 + (metadata.TemplateSeed % 3) + variant * 1.3),
|
|
new TurnoutBoardSlotEntry(4, "충북", 57.8 + (metadata.TemplateSeed % 4) + variant * 1.1),
|
|
new TurnoutBoardSlotEntry(5, "충남", 60.1 + (metadata.TemplateSeed % 2) + variant * 1.5)
|
|
};
|
|
}
|
|
|
|
private static string[] ResolveCandidateNames(string templateName)
|
|
{
|
|
if (templateName.Contains("교육감", StringComparison.Ordinal))
|
|
{
|
|
return ["김하늘", "이준호", "박소라"];
|
|
}
|
|
|
|
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
|
{
|
|
return ["김민재", "이소율", "박태훈", "최수빈"];
|
|
}
|
|
|
|
if (templateName.Contains("기초단체장", StringComparison.Ordinal))
|
|
{
|
|
return ["윤서진", "강민호", "정다온", "한지후"];
|
|
}
|
|
|
|
if (templateName.Contains("광역의원", StringComparison.Ordinal))
|
|
{
|
|
return ["송현우", "배지민", "임서아", "조하람"];
|
|
}
|
|
|
|
return ["김하늘", "이준호", "박소라", "최민석", "정서윤"];
|
|
}
|
|
|
|
private static string[] ResolveParties(int count)
|
|
{
|
|
return new[] { "더불어민주당", "국민의힘", "조국혁신당", "개혁신당", "무소속" }
|
|
.Take(count)
|
|
.ToArray();
|
|
}
|
|
|
|
private static double[] ResolveVoteShares(string templateName, int count, int variant)
|
|
{
|
|
double[] shares = templateName switch
|
|
{
|
|
var name when name.Contains("초접전", StringComparison.Ordinal) => [50.1 + (variant * 0.2), 49.6 - (variant * 0.1), 0.3, 0, 0],
|
|
var name when name.Contains("접전", StringComparison.Ordinal) => [50.9 + (variant * 0.2), 47.9 - (variant * 0.1), 1.2, 0, 0],
|
|
var name when name.Contains("당선", StringComparison.Ordinal) => [56.4 + (variant * 0.5), 31.2 - (variant * 0.2), 8.7, 3.7, 0],
|
|
var name when name.Contains("판세", StringComparison.Ordinal) => [53.1 + (variant * 0.3), 37.6 - (variant * 0.2), 6.5, 2.8, 0],
|
|
var name when name.Contains("이시각1위", StringComparison.Ordinal) => [52.4 + (variant * 0.4), 39.5 - (variant * 0.2), 5.1, 3.0, 0],
|
|
var name when name.Contains("1-3위", StringComparison.Ordinal) => [47.8 + (variant * 0.3), 34.4 + (variant * 0.1), 13.2 - (variant * 0.2), 3.1, 1.5],
|
|
var name when name.Contains("모든후보", StringComparison.Ordinal) || name.Contains("전후보", StringComparison.Ordinal) => [43.9 + (variant * 0.3), 30.5 + (variant * 0.1), 11.8, 7.2, 6.6 - (variant * 0.4)],
|
|
_ => [51.8 + (variant * 0.3), 38.7 - (variant * 0.2), 6.1, 3.4, 0]
|
|
};
|
|
|
|
shares = shares.Take(count).ToArray();
|
|
var total = shares.Sum();
|
|
for (var index = 0; index < shares.Length; index++)
|
|
{
|
|
shares[index] = Math.Round(shares[index] * 100d / total, 1, MidpointRounding.AwayFromZero);
|
|
}
|
|
|
|
var delta = 100d - shares.Sum();
|
|
shares[0] = Math.Round(shares[0] + delta, 1, MidpointRounding.AwayFromZero);
|
|
return shares;
|
|
}
|
|
|
|
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
|
|
{
|
|
if (templateName.Contains("당선", StringComparison.Ordinal))
|
|
{
|
|
return CandidateJudgement.Elected;
|
|
}
|
|
|
|
if (templateName.Contains("판세", StringComparison.Ordinal) ||
|
|
templateName.Contains("이시각1위", StringComparison.Ordinal))
|
|
{
|
|
return CandidateJudgement.Leading;
|
|
}
|
|
|
|
if (templateName.Contains("접전", StringComparison.Ordinal) ||
|
|
templateName.Contains("초접전", StringComparison.Ordinal))
|
|
{
|
|
return CandidateJudgement.None;
|
|
}
|
|
|
|
return CandidateJudgement.Leading;
|
|
}
|
|
|
|
private static ScenarioMetadata BuildScenarioMetadata(string templateName, int index, int variant)
|
|
{
|
|
var seed = index + 1;
|
|
var totalExpectedVotes = 1_800_000 + (seed * 12_500);
|
|
var turnoutVotes = 1_050_000 + (seed * 7_500) + (variant * 31_000);
|
|
var countedRate = Math.Min(97.9, 68.4 + ((seed % 6) * 2.1) + (variant * 6.4));
|
|
var countedVotes = (int)Math.Round(turnoutVotes * countedRate / 100d, MidpointRounding.AwayFromZero);
|
|
|
|
if (templateName.Contains("교육감", StringComparison.Ordinal))
|
|
{
|
|
return new ScenarioMetadata("교육감", "30", "대전광역시", "대전", "대전광역시교육감", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 58.7 + (seed % 4) + (variant * 1.9), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
|
}
|
|
|
|
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
|
{
|
|
return new ScenarioMetadata("기초의원", "44131", "천안시 가선거구", "충남", "천안시의원 가선거구", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 54.8 + (seed % 5) + (variant * 1.7), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
|
}
|
|
|
|
if (templateName.Contains("기초단체장", StringComparison.Ordinal))
|
|
{
|
|
return new ScenarioMetadata("기초단체장", "44131", "천안시", "충남", "천안시장", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.1 + (seed % 4) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
|
}
|
|
|
|
if (templateName.Contains("광역의원", StringComparison.Ordinal))
|
|
{
|
|
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
|
}
|
|
|
|
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
|
}
|
|
|
|
private static string CapturePgm(PgmWindow? pgmWindow, string outputPath, bool skipCapture)
|
|
{
|
|
if (skipCapture || pgmWindow is null)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
|
using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Handle, pgmWindow.Value.Bounds);
|
|
bitmap.Save(outputPath, ImageFormat.Png);
|
|
return ComputeSha256(outputPath);
|
|
}
|
|
|
|
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
|
|
{
|
|
var width = Math.Max(1, bounds.Width);
|
|
var height = Math.Max(1, bounds.Height);
|
|
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
|
using var graphics = Graphics.FromImage(bitmap);
|
|
|
|
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
|
|
return bitmap;
|
|
}
|
|
|
|
private static string ComputeSha256(string path)
|
|
{
|
|
using var stream = File.OpenRead(path);
|
|
using var sha256 = SHA256.Create();
|
|
return Convert.ToHexString(sha256.ComputeHash(stream));
|
|
}
|
|
|
|
private static PgmWindow? TryFindPgmWindow()
|
|
{
|
|
var process = Process.GetProcessesByName("Tornado3")
|
|
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
|
|
if (process is null || process.MainWindowHandle == IntPtr.Zero)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!GetWindowRect(process.MainWindowHandle, out var rect))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
|
|
}
|
|
|
|
private static void WriteReports(LiveCutValidationOptions options, IReadOnlyList<LiveCutValidationResult> results)
|
|
{
|
|
var csvPath = Path.Combine(options.OutputPath, "results.csv");
|
|
var jsonPath = Path.Combine(options.OutputPath, "results.json");
|
|
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
|
|
|
var csv = new StringBuilder();
|
|
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
|
|
foreach (var result in results)
|
|
{
|
|
csv.AppendLine(string.Join(",",
|
|
Csv(result.Index.ToString()),
|
|
Csv(result.TemplateId),
|
|
Csv(result.TemplateName),
|
|
Csv(result.CutName),
|
|
Csv(result.Channel),
|
|
Csv(result.Phase),
|
|
Csv(result.Success.ToString()),
|
|
Csv(result.VisualChanged.ToString()),
|
|
Csv(result.OutputVisibleInPgm.ToString()),
|
|
Csv(result.CaptureAPath ?? string.Empty),
|
|
Csv(result.CaptureBPath ?? string.Empty),
|
|
Csv(result.HashA ?? string.Empty),
|
|
Csv(result.HashB ?? string.Empty),
|
|
Csv(result.Detail ?? string.Empty)));
|
|
}
|
|
|
|
File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
|
|
File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
|
|
|
|
var successCount = results.Count(result => result.Success);
|
|
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
|
var unchanged = results.Where(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged).ToList();
|
|
var failures = results.Where(result => !result.Success).ToList();
|
|
|
|
var summary = new StringBuilder();
|
|
summary.AppendLine("# Live Cut Validation");
|
|
summary.AppendLine();
|
|
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
|
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
|
summary.AppendLine($"- Output: {options.OutputPath}");
|
|
summary.AppendLine($"- Success: {successCount}/{results.Count}");
|
|
summary.AppendLine($"- Visual Changed: {changedCount}");
|
|
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
|
|
summary.AppendLine($"- Failures: {failures.Count}");
|
|
summary.AppendLine();
|
|
|
|
if (unchanged.Count > 0)
|
|
{
|
|
summary.AppendLine("## Unchanged Captures");
|
|
summary.AppendLine();
|
|
foreach (var result in unchanged)
|
|
{
|
|
summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
{
|
|
summary.AppendLine("## Failures");
|
|
summary.AppendLine();
|
|
foreach (var result in failures)
|
|
{
|
|
summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
}
|
|
|
|
summary.AppendLine("## Files");
|
|
summary.AppendLine();
|
|
summary.AppendLine("- `results.csv`");
|
|
summary.AppendLine("- `results.json`");
|
|
summary.AppendLine("- `*_A.png`, `*_B.png`");
|
|
|
|
File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
|
|
}
|
|
|
|
private static string Csv(string value)
|
|
{
|
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
}
|
|
|
|
private static List<LiveCutWorkItem> ApplyTemplateFilter(IEnumerable<LiveCutWorkItem> items, string? filter)
|
|
{
|
|
var list = items.ToList();
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return list;
|
|
}
|
|
|
|
var exactMatches = list
|
|
.Where(item =>
|
|
string.Equals(item.Template.Id, filter, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(item.Template.Name, filter, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
if (exactMatches.Count > 0)
|
|
{
|
|
return exactMatches;
|
|
}
|
|
|
|
return list
|
|
.Where(item =>
|
|
item.Template.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
|
item.Template.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
}
|
|
|
|
private static List<LiveCutWorkItem> ApplyCutFilter(IEnumerable<LiveCutWorkItem> items, string? cutName)
|
|
{
|
|
var list = items.ToList();
|
|
if (string.IsNullOrWhiteSpace(cutName))
|
|
{
|
|
return list;
|
|
}
|
|
|
|
var exactMatches = list
|
|
.Where(item => string.Equals(item.Cut.Name, cutName, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
if (exactMatches.Count > 0)
|
|
{
|
|
return exactMatches;
|
|
}
|
|
|
|
return list
|
|
.Where(item => item.Cut.Name.Contains(cutName, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
}
|
|
|
|
private static string SanitizeFileName(string value)
|
|
{
|
|
var invalidChars = Path.GetInvalidFileNameChars();
|
|
var builder = new StringBuilder(value.Length);
|
|
foreach (var character in value)
|
|
{
|
|
builder.Append(invalidChars.Contains(character) ? '_' : character);
|
|
}
|
|
|
|
var sanitized = builder.ToString().Trim();
|
|
if (sanitized.Length > 80)
|
|
{
|
|
sanitized = sanitized[..80];
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(sanitized) ? "cut" : sanitized;
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
|
|
|
|
private sealed record LiveCutValidationOptions
|
|
{
|
|
public string ImageRootPath { get; init; } = string.Empty;
|
|
public string OutputPath { get; init; } = string.Empty;
|
|
public string StationLogoPath { get; init; } = string.Empty;
|
|
public string? Filter { get; init; }
|
|
public int? Limit { get; init; }
|
|
public int OnAirDelayMs { get; init; } = 900;
|
|
public int BetweenDelayMs { get; init; } = 250;
|
|
public bool IncludeVideoWall { get; init; }
|
|
public bool SwapTopTwoCandidates { get; init; }
|
|
|
|
public static LiveCutValidationOptions Parse(string[] args)
|
|
{
|
|
var repoRoot = Environment.CurrentDirectory;
|
|
var options = new LiveCutValidationOptions
|
|
{
|
|
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
|
OutputPath = Path.Combine(repoRoot, "artifacts", "live-cut-validation", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
|
|
StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
|
|
};
|
|
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
switch (args[index])
|
|
{
|
|
case "--image-root":
|
|
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
|
break;
|
|
case "--output":
|
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
|
break;
|
|
case "--filter":
|
|
options = options with { Filter = RequireValue(args, ref index, "--filter") };
|
|
break;
|
|
case "--limit":
|
|
options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
|
|
break;
|
|
case "--onair-delay-ms":
|
|
options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
|
|
break;
|
|
case "--between-delay-ms":
|
|
options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
|
|
break;
|
|
case "--include-videowall":
|
|
options = options with { IncludeVideoWall = true };
|
|
break;
|
|
case "--swap-top-two":
|
|
options = options with { SwapTopTwoCandidates = true };
|
|
break;
|
|
default:
|
|
throw new ArgumentException($"Unknown option: {args[index]}");
|
|
}
|
|
}
|
|
|
|
return options with
|
|
{
|
|
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
|
OutputPath = Path.GetFullPath(options.OutputPath),
|
|
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
|
};
|
|
}
|
|
|
|
private static string RequireValue(string[] args, ref int index, string optionName)
|
|
{
|
|
index++;
|
|
if (index >= args.Length)
|
|
{
|
|
throw new ArgumentException($"Missing value for {optionName}.");
|
|
}
|
|
|
|
return args[index];
|
|
}
|
|
}
|
|
|
|
private sealed record CutDebugCoverageOptions
|
|
{
|
|
public string ImageRootPath { get; init; } = string.Empty;
|
|
public string OutputPath { get; init; } = string.Empty;
|
|
public string? Filter { get; init; }
|
|
public string? CutName { get; init; }
|
|
public int? Limit { get; init; }
|
|
public bool IncludeVideoWall { get; init; }
|
|
public bool ExcludeHistoricalTurnout { get; init; }
|
|
|
|
public static CutDebugCoverageOptions Parse(string[] args)
|
|
{
|
|
var repoRoot = Environment.CurrentDirectory;
|
|
var options = new CutDebugCoverageOptions
|
|
{
|
|
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
|
OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-coverage", DateTime.Now.ToString("yyyyMMdd_HHmmss"))
|
|
};
|
|
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
switch (args[index])
|
|
{
|
|
case "--image-root":
|
|
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
|
break;
|
|
case "--output":
|
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
|
break;
|
|
case "--filter":
|
|
options = options with { Filter = RequireValue(args, ref index, "--filter") };
|
|
break;
|
|
case "--cut-name":
|
|
options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
|
|
break;
|
|
case "--limit":
|
|
options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
|
|
break;
|
|
case "--include-videowall":
|
|
options = options with { IncludeVideoWall = true };
|
|
break;
|
|
case "--exclude-historical-turnout":
|
|
options = options with { ExcludeHistoricalTurnout = true };
|
|
break;
|
|
default:
|
|
throw new ArgumentException($"Unknown option: {args[index]}");
|
|
}
|
|
}
|
|
|
|
return options with
|
|
{
|
|
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
|
OutputPath = Path.GetFullPath(options.OutputPath)
|
|
};
|
|
}
|
|
|
|
private static string RequireValue(string[] args, ref int index, string optionName)
|
|
{
|
|
index++;
|
|
if (index >= args.Length)
|
|
{
|
|
throw new ArgumentException($"Missing value for {optionName}.");
|
|
}
|
|
|
|
return args[index];
|
|
}
|
|
}
|
|
|
|
private sealed record CutDebugSweepOptions
|
|
{
|
|
public string ImageRootPath { get; init; } = string.Empty;
|
|
public string OutputPath { get; init; } = string.Empty;
|
|
public string StationLogoPath { get; init; } = string.Empty;
|
|
public string? Filter { get; init; }
|
|
public string? CutName { get; init; }
|
|
public int? Limit { get; init; }
|
|
public int? MaxItems { get; init; }
|
|
public int OnAirDelayMs { get; init; } = 1200;
|
|
public int BetweenDelayMs { get; init; } = 300;
|
|
public bool IncludeVideoWall { get; init; }
|
|
public bool ExcludeHistoricalTurnout { get; init; }
|
|
public string? KindFilter { get; init; }
|
|
public string? KeyFilter { get; init; }
|
|
public CutDebugSweepMode Mode { get; init; } = CutDebugSweepMode.Disable;
|
|
public bool SwapTopTwoCandidates { get; init; }
|
|
|
|
public static CutDebugSweepOptions Parse(string[] args)
|
|
{
|
|
var repoRoot = Environment.CurrentDirectory;
|
|
var options = new CutDebugSweepOptions
|
|
{
|
|
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
|
OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-sweep", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
|
|
StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
|
|
};
|
|
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
switch (args[index])
|
|
{
|
|
case "--image-root":
|
|
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
|
break;
|
|
case "--output":
|
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
|
break;
|
|
case "--station-logo":
|
|
options = options with { StationLogoPath = RequireValue(args, ref index, "--station-logo") };
|
|
break;
|
|
case "--filter":
|
|
options = options with { Filter = RequireValue(args, ref index, "--filter") };
|
|
break;
|
|
case "--cut-name":
|
|
options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
|
|
break;
|
|
case "--limit":
|
|
options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
|
|
break;
|
|
case "--max-items":
|
|
options = options with { MaxItems = int.Parse(RequireValue(args, ref index, "--max-items")) };
|
|
break;
|
|
case "--onair-delay-ms":
|
|
options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
|
|
break;
|
|
case "--between-delay-ms":
|
|
options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
|
|
break;
|
|
case "--include-videowall":
|
|
options = options with { IncludeVideoWall = true };
|
|
break;
|
|
case "--exclude-historical-turnout":
|
|
options = options with { ExcludeHistoricalTurnout = true };
|
|
break;
|
|
case "--swap-top-two":
|
|
options = options with { SwapTopTwoCandidates = true };
|
|
break;
|
|
case "--kind":
|
|
options = options with { KindFilter = RequireValue(args, ref index, "--kind") };
|
|
break;
|
|
case "--key":
|
|
options = options with { KeyFilter = RequireValue(args, ref index, "--key") };
|
|
break;
|
|
case "--mode":
|
|
options = options with { Mode = ParseMode(RequireValue(args, ref index, "--mode")) };
|
|
break;
|
|
default:
|
|
throw new ArgumentException($"Unknown option: {args[index]}");
|
|
}
|
|
}
|
|
|
|
return options with
|
|
{
|
|
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
|
OutputPath = Path.GetFullPath(options.OutputPath),
|
|
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
|
};
|
|
}
|
|
|
|
public bool IncludeItem(string key, CutDebugItemKind kind)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(KeyFilter) &&
|
|
!key.Contains(KeyFilter, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(KindFilter) &&
|
|
!string.Equals(kind.ToString(), KindFilter, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static CutDebugSweepMode ParseMode(string raw)
|
|
{
|
|
return raw.ToLowerInvariant() switch
|
|
{
|
|
"off" or "disable" => CutDebugSweepMode.Disable,
|
|
"replace" or "override" => CutDebugSweepMode.Replace,
|
|
_ => throw new ArgumentException($"Unknown sweep mode: {raw}")
|
|
};
|
|
}
|
|
|
|
private static string RequireValue(string[] args, ref int index, string optionName)
|
|
{
|
|
index++;
|
|
if (index >= args.Length)
|
|
{
|
|
throw new ArgumentException($"Missing value for {optionName}.");
|
|
}
|
|
|
|
return args[index];
|
|
}
|
|
}
|
|
|
|
private enum CutDebugSweepMode
|
|
{
|
|
Disable,
|
|
Replace
|
|
}
|
|
|
|
private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
|
|
|
|
private sealed class CutDebugSweepResult
|
|
{
|
|
public int Index { get; init; }
|
|
public string TemplateId { get; init; } = string.Empty;
|
|
public string TemplateName { get; init; } = string.Empty;
|
|
public string CutName { get; init; } = string.Empty;
|
|
public string Channel { get; init; } = string.Empty;
|
|
public string ScenePath { get; set; } = string.Empty;
|
|
public bool Success { get; set; }
|
|
public int ItemCount { get; set; }
|
|
public string? BaselineAPath { get; set; }
|
|
public string? BaselineBPath { get; set; }
|
|
public string? BaselineHash { get; set; }
|
|
public string? NoiseDiffPath { get; set; }
|
|
public int NoiseChangedPixels { get; set; }
|
|
public int SignificanceThresholdPixels { get; set; }
|
|
public List<CutDebugItemSweepResult> Items { get; } = [];
|
|
public string? Detail { get; set; }
|
|
}
|
|
|
|
private sealed class CutDebugCoverageResult
|
|
{
|
|
public int Index { get; init; }
|
|
public string TemplateId { get; init; } = string.Empty;
|
|
public string TemplateName { get; init; } = string.Empty;
|
|
public string CutName { get; init; } = string.Empty;
|
|
public string Channel { get; init; } = string.Empty;
|
|
public string? ScenePath { get; set; }
|
|
public bool Success { get; set; }
|
|
public bool ExcludedHistoricalTurnout { get; set; }
|
|
public int SceneVariableCount { get; set; }
|
|
public int ItemCount { get; set; }
|
|
public int TextItemCount { get; set; }
|
|
public int ImageItemCount { get; set; }
|
|
public int CounterItemCount { get; set; }
|
|
public int StyleItemCount { get; set; }
|
|
public int VisibilityItemCount { get; set; }
|
|
public string? SampleKeys { get; set; }
|
|
public string? Detail { get; set; }
|
|
}
|
|
|
|
private sealed class CutDebugItemSweepResult
|
|
{
|
|
public string Key { get; init; } = string.Empty;
|
|
public string Kind { get; init; } = string.Empty;
|
|
public string GroupLabel { get; init; } = string.Empty;
|
|
public string? CapturePath { get; init; }
|
|
public string? DiffPath { get; init; }
|
|
public string? Hash { get; init; }
|
|
public int ChangedPixels { get; init; }
|
|
public double ChangeRatio { get; init; }
|
|
public double AverageChannelDelta { get; init; }
|
|
public bool SignificantChange { get; init; }
|
|
public string? BoundingBox { get; init; }
|
|
public string? LogPreview { get; init; }
|
|
public string? Detail { get; init; }
|
|
}
|
|
|
|
private readonly record struct SweepCaptureResult(string Path, string Hash);
|
|
|
|
private readonly record struct ImageDiffResult(
|
|
int ChangedPixels,
|
|
double ChangeRatio,
|
|
double AverageChannelDelta,
|
|
string? BoundingBox);
|
|
|
|
private sealed class LiveCutValidationResult
|
|
{
|
|
public int Index { get; init; }
|
|
public string TemplateId { get; init; } = string.Empty;
|
|
public string TemplateName { get; init; } = string.Empty;
|
|
public string CutName { get; init; } = string.Empty;
|
|
public string Channel { get; init; } = string.Empty;
|
|
public string Phase { get; init; } = string.Empty;
|
|
public bool Success { get; set; }
|
|
public bool VisualChanged { get; set; }
|
|
public bool OutputVisibleInPgm { get; set; }
|
|
public string? CaptureAPath { get; set; }
|
|
public string? CaptureBPath { get; set; }
|
|
public string? HashA { get; set; }
|
|
public string? HashB { get; set; }
|
|
public string? Detail { get; set; }
|
|
}
|
|
|
|
private readonly record struct LiveCutWorkItem(FormatTemplateDefinition Template, FormatCutDefinition Cut);
|
|
|
|
private readonly record struct ScenarioMetadata(
|
|
string ElectionType,
|
|
string DistrictCode,
|
|
string DistrictName,
|
|
string RegionName,
|
|
string ElectionDistrictName,
|
|
int TotalExpectedVotes,
|
|
int TurnoutVotes,
|
|
int CountedVotes,
|
|
double CountedRate,
|
|
double NationalTurnoutRate,
|
|
DateTimeOffset ReceivedAt,
|
|
int TemplateSeed);
|
|
|
|
private readonly record struct HistoricalTurnoutSeed(
|
|
int ElectionOrder,
|
|
int Year,
|
|
double TurnoutRate);
|
|
|
|
private static readonly IReadOnlyList<HistoricalTurnoutSeed> HistoricalTurnoutLocalSeeds =
|
|
[
|
|
new(3, 2002, 55.4),
|
|
new(4, 2006, 42.8),
|
|
new(5, 2010, 56.9),
|
|
new(6, 2014, 45.1),
|
|
new(7, 2018, 45.8),
|
|
new(8, 2022, 55.7)
|
|
];
|
|
|
|
private static readonly IReadOnlyList<HistoricalTurnoutSeed> HistoricalTurnoutEducationSeeds =
|
|
[
|
|
new(5, 2010, 53.6),
|
|
new(6, 2014, 47.9),
|
|
new(7, 2018, 56.1),
|
|
new(8, 2022, 50.4)
|
|
];
|
|
|
|
private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds);
|
|
|
|
private readonly record struct Rect(int Left, int Top, int Width, int Height);
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private readonly struct NativeRect
|
|
{
|
|
public int Left { get; init; }
|
|
public int Top { get; init; }
|
|
public int Right { get; init; }
|
|
public int Bottom { get; init; }
|
|
}
|
|
}
|