feat(scanner): add native artifact pipeline

Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
This commit is contained in:
AzuTear
2026-07-09 23:30:42 +02:00
parent 28d60eb915
commit 639b0b7f59
92 changed files with 13606 additions and 1703 deletions
+749 -7
View File
@@ -19,6 +19,7 @@ namespace GenshinAssistant.InputHelper;
internal static class Program
{
private static IntPtr _genshinHwnd = IntPtr.Zero;
private static readonly NativeScannerService Scanner = new();
private static int Main()
{
@@ -109,9 +110,8 @@ internal static class Program
var targetX = GetInt(root, "x");
var targetY = GetInt(root, "y");
// Bare SetCursorPos then a batched down+up click, matching the
// verified Inventory Kamera sequence: no extra move event, no
// gap between move and click.
// Bare SetCursorPos then a batched down+up click: no extra move
// event, no gap between move and click.
Native.SetCursorPos(targetX, targetY);
Native.GetCursorPos(out var pt);
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
@@ -238,6 +238,36 @@ internal static class Program
break;
}
case "scanner-data-status":
response["scanner"] = Scanner.DataStatus(GetString(root, "dataDir"));
break;
case "scanner-catalog":
response["scanner"] = IkInventoryLists.CatalogPayload(GetString(root, "dataDir"));
break;
case "scanner-preflight":
response["scanner"] = Scanner.Preflight(
GetString(root, "dataDir"),
NormalizeScannerCategory(GetString(root, "category")));
break;
case "scanner-start":
response["scanner"] = Scanner.Start(
GetString(root, "dataDir"),
GetString(root, "outputRoot"),
GetInt(root, "limit"),
NormalizeScannerCategory(GetString(root, "category")));
break;
case "scanner-stop":
response["scanner"] = Scanner.Stop();
break;
case "scanner-status":
response["scanner"] = Scanner.Status();
break;
default:
response["ok"] = false;
response["error"] = "unknown op";
@@ -262,6 +292,24 @@ internal static class Program
public bool F9;
}
private static string NormalizeScannerCategory(string category)
{
var value = (category ?? "").Trim().ToLowerInvariant();
return value switch
{
"" => "artifacts",
"artifact" => "artifacts",
"artifacts" => "artifacts",
"weapon" => "weapons",
"weapons" => "weapons",
"character" => "characters",
"characters" => "characters",
"material" => "materials",
"materials" => "materials",
_ => value,
};
}
private struct FocusInfo
{
public IntPtr Hwnd;
@@ -308,10 +356,9 @@ internal static class Program
}
// Plain SetForegroundWindow from a background process is silently refused by
// Windows' foreground lock. Inventory Kamera and other reliable automation
// tools bypass it by attaching the calling thread's input queue to the target
// (and current-foreground) window thread and clearing the lock timeout, so the
// foreground change is honored. Without this the auto-scan aborts with
// Windows' foreground lock. Attach the calling thread's input queue to the
// target (and current-foreground) window thread and clear the lock timeout,
// so the foreground change is honored. Without this the auto-scan aborts with
// "Genshin konnte nicht in den Vordergrund geholt werden".
private static bool ForceForeground(IntPtr hwnd)
{
@@ -478,6 +525,701 @@ internal static class Program
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
return false;
}
private sealed class NativeScannerService
{
private readonly object gate = new();
private ScannerRunStatus current = ScannerRunStatus.Idle();
private bool stopRequested;
private Task? worker;
public object DataStatus(string dataDir)
{
return IkInventoryLists.Load(dataDir).ToPayload();
}
public object Preflight(string dataDir, string category)
{
var data = IkInventoryLists.Load(dataDir);
var supportedCategories = data.SupportedCategoriesPayload();
if (!data.Valid)
{
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = false,
genshinFound = false,
bounds = (object?)null,
isSixteenNine = false,
grid = NativeGrid.Empty().ToPayload(),
ready = false,
blockReason = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}",
};
}
if (category != "artifacts")
{
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = false,
genshinFound = false,
bounds = (object?)null,
isSixteenNine = false,
grid = NativeGrid.Empty().ToPayload(),
ready = false,
blockReason = NativeCaptureUnsupportedMessage(category),
};
}
var bounds = GetGenshinClientBounds();
var grid = bounds is null ? NativeGrid.Empty() : NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
var isSixteenNine = bounds is not null && IsSixteenNine(bounds.Value.Width, bounds.Value.Height);
var layoutReady = bounds is not null && isSixteenNine && grid.Targets.Count >= 32;
var visual = layoutReady ? CaptureVisualSignal(bounds!.Value) : null;
var ready = layoutReady && (visual?.Ready ?? false);
var blockReason = ready
? ""
: bounds is null
? "Genshin window not found."
: !isSixteenNine
? $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9."
: grid.Targets.Count < 32
? $"Native grid incomplete: {grid.Targets.Count}/32 targets."
: visual?.BlockReason ?? "Genshin capture visual preflight failed.";
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = true,
genshinFound = bounds is not null,
bounds = bounds is null
? null
: new
{
left = bounds.Value.Left,
top = bounds.Value.Top,
width = bounds.Value.Width,
height = bounds.Value.Height,
},
isSixteenNine,
grid = grid.ToPayload(),
visual = visual?.ToPayload(),
ready,
blockReason,
};
}
public object Start(string dataDir, string outputRoot, int limit, string category)
{
lock (gate)
{
if (current.Running) return current.ToPayload();
stopRequested = false;
var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800);
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
var data = IkInventoryLists.Load(dataDir);
current.DataVersion = data.Version;
current.SupportedCategories = data.SupportedCategoriesPayload();
if (!data.Valid)
{
current.Running = false;
current.Status = "blocked";
current.Message = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}";
return current.ToPayload();
}
if (category != "artifacts")
{
current.Running = false;
current.Status = "blocked";
current.Message = NativeCaptureUnsupportedMessage(category);
return current.ToPayload();
}
worker = Task.Run(() => RunCaptureLoop(dataDir, outputRoot, safeLimit, runId, category));
return current.ToPayload();
}
}
public object Stop()
{
lock (gate)
{
stopRequested = true;
current.Message = current.Running ? "stop requested" : current.Message;
return current.ToPayload();
}
}
public object Status()
{
lock (gate)
{
return current.ToPayload();
}
}
private void RunCaptureLoop(string dataDir, string outputRoot, int limit, string runId, string category)
{
try
{
var data = IkInventoryLists.Load(dataDir);
if (!data.Valid)
{
Finish("blocked", $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}");
return;
}
lock (gate)
{
current.DataVersion = data.Version;
current.SupportedCategories = data.SupportedCategoriesPayload();
}
if (category != "artifacts")
{
Finish("blocked", NativeCaptureUnsupportedMessage(category));
return;
}
var bounds = GetGenshinClientBounds();
if (bounds is null)
{
Finish("blocked", "Genshin window not found.");
return;
}
if (!IsSixteenNine(bounds.Value.Width, bounds.Value.Height))
{
Finish("blocked", $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9.");
return;
}
var focus = FocusGenshinWindow(includeProcessNames: false);
if (!focus.Focused)
{
Finish("blocked", "Genshin could not be focused.");
return;
}
var visual = CaptureVisualSignal(bounds.Value);
if (!visual.Ready)
{
Finish("blocked", visual.BlockReason);
return;
}
var runDir = PrepareRunDirectory(outputRoot, runId);
var manifestPath = Path.Combine(runDir, "manifest.json");
var jobsPath = Path.Combine(runDir, "capture-jobs.jsonl");
var statusPath = Path.Combine(runDir, "status.json");
var grid = NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
var detailRect = DetailRect(bounds.Value.Width, bounds.Value.Height);
lock (gate)
{
current.RunDir = runDir;
current.ManifestPath = manifestPath;
current.JobsPath = jobsPath;
current.StatusPath = statusPath;
current.SupportedCategories = data.SupportedCategoriesPayload();
}
WriteRunManifest(manifestPath, data, bounds.Value, grid, detailRect, limit, runId, category);
WriteStatusFileSafe();
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
var captured = 0;
var page = 1;
while (captured < limit)
{
foreach (var target in grid.Targets)
{
if (ShouldStop())
{
Finish("stopped", "stop requested");
return;
}
if (captured >= limit) break;
var screenX = bounds.Value.Left + target.X;
var screenY = bounds.Value.Top + target.Y;
Native.SetCursorPos(screenX, screenY);
var sent = SendMouseClickBatch();
Thread.Sleep(190);
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
using (var card = CaptureArtifactCard(bounds.Value))
{
card.Save(cardPath, ImageFormat.Png);
}
var job = new NativeCaptureJob(
captured + 1,
page,
target.Row,
target.Col,
target.X,
target.Y,
screenX,
screenY,
sent,
Path.GetFileName(cardPath),
cardPath,
DateTimeOffset.Now,
detailRect.Width,
detailRect.Height,
category);
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
captured++;
lock (gate)
{
current.Captured = captured;
current.Queued = captured;
current.Clicked = captured;
current.Pages = page;
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
current.LastArtifactPath = cardPath;
current.LastJob = job.ToPayload();
current.ActiveMs = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
}
WriteStatusFileSafe();
}
if (captured >= limit) break;
ScrollOneArtifactPage(grid, bounds.Value);
page++;
lock (gate)
{
current.Pages = page;
current.Message = $"scrolled to page {page}";
}
WriteStatusFileSafe();
Thread.Sleep(120);
}
Finish("done", $"captured {captured} {category} card crops");
}
catch (Exception ex)
{
Finish("blocked", ex.Message);
}
}
private static string PrepareRunDirectory(string outputRoot, string runId)
{
var root = string.IsNullOrWhiteSpace(outputRoot)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GenshinArtifactAssistant", "native-scans")
: outputRoot;
var runDir = Path.Combine(root, runId);
Directory.CreateDirectory(runDir);
return runDir;
}
private static Bitmap CaptureArtifactCard(Rect bounds)
{
var card = DetailRect(bounds.Width, bounds.Height);
var bitmap = new Bitmap(card.Width, card.Height, PixelFormat.Format32bppArgb);
using var graphics = Graphics.FromImage(bitmap);
graphics.CopyFromScreen(bounds.Left + card.Left, bounds.Top + card.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
return bitmap;
}
private static Rect DetailRect(int width, int height)
{
return new Rect
{
Left = (int)Math.Round(width * 0.681),
Top = (int)Math.Round(height * 0.111),
Width = Math.Max(1, (int)Math.Round(width * 0.256)),
Height = Math.Max(1, (int)Math.Round(height * 0.776)),
};
}
private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
{
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
Thread.Sleep(25);
for (var index = 0; index < 39; index++)
{
SendMouseWheel(-120);
Thread.Sleep(1);
}
}
private bool ShouldStop()
{
lock (gate)
{
if (stopRequested) return true;
}
var state = GetCursorState();
return state.Escape || state.Enter || state.F9;
}
private void Finish(string status, string message)
{
lock (gate)
{
current.Running = false;
current.Status = status;
current.Message = message;
current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue
? 0
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
stopRequested = false;
}
WriteStatusFileSafe();
}
private void WriteStatusFileSafe()
{
string statusPath;
object payload;
lock (gate)
{
statusPath = current.StatusPath;
payload = current.ToPayload();
}
if (string.IsNullOrWhiteSpace(statusPath)) return;
try
{
NativeScannerFiles.WriteJson(statusPath, new { scanner = payload });
}
catch
{
// Status files are a recovery aid; scan control remains in memory.
}
}
private static void WriteRunManifest(
string manifestPath,
IkInventoryListStatus data,
Rect bounds,
NativeGrid grid,
Rect detailRect,
int target,
string runId,
string category)
{
NativeScannerFiles.WriteJson(manifestPath, new
{
schemaVersion = 1,
kind = "native-ik-category-card-crop-scan",
runId,
category,
createdAt = DateTimeOffset.Now,
target,
data = data.ToPayload(),
bounds = new
{
left = bounds.Left,
top = bounds.Top,
width = bounds.Width,
height = bounds.Height,
},
grid = grid.ToPayload(),
detailRect = new
{
left = detailRect.Left,
top = detailRect.Top,
width = detailRect.Width,
height = detailRect.Height,
},
outputs = new
{
jobs = "capture-jobs.jsonl",
status = "status.json",
},
downstream = new
{
queue = "capture-jobs.jsonl",
next = "ocr-parse-store",
evaluation = "deferred",
category,
},
});
}
private static bool IsSixteenNine(int width, int height)
{
if (height <= 0) return false;
const double ratio = 16.0 / 9.0;
var actual = width / (double)height;
return Math.Abs(actual - ratio) <= ratio * 0.02;
}
private static NativeVisualSignal CaptureVisualSignal(Rect bounds)
{
try
{
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
}
return NativeVisualSignal.FromBitmap(bitmap);
}
catch (Exception ex)
{
return NativeVisualSignal.Blocked($"Genshin capture visual preflight failed: {ex.Message}");
}
}
private static string NativeCaptureUnsupportedMessage(string category)
=> $"Native capture for category '{category}' is not implemented yet; IK catalog is available only.";
}
private sealed class NativeVisualSignal
{
public bool Ready { get; private init; }
public int Samples { get; private init; }
public double WhitePct { get; private init; }
public double DarkPct { get; private init; }
public double ColorPct { get; private init; }
public double LumaStdDev { get; private init; }
public string BlockReason { get; private init; } = "";
public static NativeVisualSignal Blocked(string reason) => new()
{
Ready = false,
Samples = 0,
BlockReason = reason,
};
public static NativeVisualSignal FromBitmap(Bitmap bitmap)
{
var stepX = Math.Max(1, bitmap.Width / 120);
var stepY = Math.Max(1, bitmap.Height / 80);
var samples = 0;
var white = 0;
var dark = 0;
var colorful = 0;
double lumaSum = 0;
double lumaSqSum = 0;
for (var y = 0; y < bitmap.Height; y += stepY)
{
for (var x = 0; x < bitmap.Width; x += stepX)
{
var pixel = bitmap.GetPixel(x, y);
var max = Math.Max(pixel.R, Math.Max(pixel.G, pixel.B));
var min = Math.Min(pixel.R, Math.Min(pixel.G, pixel.B));
var luma = 0.2126 * pixel.R + 0.7152 * pixel.G + 0.0722 * pixel.B;
samples++;
if (pixel.R >= 245 && pixel.G >= 245 && pixel.B >= 245) white++;
if (pixel.R <= 12 && pixel.G <= 12 && pixel.B <= 12) dark++;
if (max - min >= 18) colorful++;
lumaSum += luma;
lumaSqSum += luma * luma;
}
}
if (samples <= 0) return Blocked("Genshin capture visual preflight produced no samples.");
var mean = lumaSum / samples;
var variance = Math.Max(0, (lumaSqSum / samples) - mean * mean);
var stdDev = Math.Sqrt(variance);
var whitePct = white * 100.0 / samples;
var darkPct = dark * 100.0 / samples;
var colorPct = colorful * 100.0 / samples;
var blankWhite = whitePct >= 96 && stdDev <= 10;
var blankDark = darkPct >= 96 && stdDev <= 10;
var tooUniform = stdDev <= 4 && colorPct <= 1.5;
var ready = !(blankWhite || blankDark || tooUniform);
var reason = ready
? ""
: blankWhite
? "Genshin capture is blank or almost entirely white."
: blankDark
? "Genshin capture is blank or almost entirely black."
: "Genshin capture is too uniform for native scanning.";
return new NativeVisualSignal
{
Ready = ready,
Samples = samples,
WhitePct = Math.Round(whitePct, 1),
DarkPct = Math.Round(darkPct, 1),
ColorPct = Math.Round(colorPct, 1),
LumaStdDev = Math.Round(stdDev, 1),
BlockReason = reason,
};
}
public object ToPayload() => new
{
ready = Ready,
samples = Samples,
whitePct = WhitePct,
darkPct = DarkPct,
colorPct = ColorPct,
lumaStdDev = LumaStdDev,
blockReason = BlockReason,
};
}
private sealed class ScannerRunStatus
{
public bool Running { get; set; }
public string Status { get; set; } = "idle";
public string RunId { get; set; } = "";
public int Target { get; set; }
public int Captured { get; set; }
public int Queued { get; set; }
public int Clicked { get; set; }
public int Pages { get; set; }
public int ActiveMs { get; set; }
public string Message { get; set; } = "";
public string OutputRoot { get; set; } = "";
public string RunDir { get; set; } = "";
public string ManifestPath { get; set; } = "";
public string JobsPath { get; set; } = "";
public string StatusPath { get; set; } = "";
public string DataVersion { get; set; } = "";
public string Category { get; set; } = "artifacts";
public string LastArtifactPath { get; set; } = "";
public object? SupportedCategories { get; set; }
public object? LastJob { get; set; }
public DateTimeOffset StartedAt { get; set; }
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
public static ScannerRunStatus Started(string runId, int target, string outputRoot, string category) => new()
{
Running = true,
Status = "running",
RunId = runId,
Category = category,
Target = target,
OutputRoot = outputRoot,
StartedAt = DateTimeOffset.Now,
Message = $"native {category} scanner started",
};
public object ToPayload() => new
{
running = Running,
status = Status,
runId = RunId,
target = Target,
captured = Captured,
queued = Queued,
clicked = Clicked,
pages = Pages,
activeMs = ActiveMs,
message = Message,
outputRoot = OutputRoot,
runDir = RunDir,
manifestPath = ManifestPath,
jobsPath = JobsPath,
statusPath = StatusPath,
dataVersion = DataVersion,
category = Category,
lastArtifactPath = LastArtifactPath,
supportedCategories = SupportedCategories,
lastJob = LastJob,
};
}
private sealed record NativeCaptureJob(
int Sequence,
int Page,
int Row,
int Col,
int ClientX,
int ClientY,
int ScreenX,
int ScreenY,
uint ClickEventsSent,
string RelativePath,
string AbsolutePath,
DateTimeOffset CapturedAt,
int DetailWidth,
int DetailHeight,
string Category)
{
public object ToPayload() => new
{
sequence = Sequence,
category = Category,
page = Page,
row = Row,
col = Col,
clientX = ClientX,
clientY = ClientY,
screenX = ScreenX,
screenY = ScreenY,
clickEventsSent = ClickEventsSent,
relativePath = RelativePath,
absolutePath = AbsolutePath,
capturedAt = CapturedAt,
detail = new
{
width = DetailWidth,
height = DetailHeight,
},
kind = $"{Category.TrimEnd('s')}-detail-card-crop",
downstream = "ocr-parse-store",
};
}
private sealed class NativeGrid
{
public List<NativeGridTarget> Targets { get; init; } = new();
public int Rows { get; init; }
public int Cols { get; init; }
public int AnchorX { get; init; }
public int AnchorY { get; init; }
public static NativeGrid Empty() => new();
public static NativeGrid ForClient(int width, int height)
{
const int rows = 4;
const int cols = 8;
var startX = (int)Math.Round(width * 0.093);
var startY = (int)Math.Round(height * 0.235);
var stepX = (int)Math.Round(width * 0.076);
var stepY = (int)Math.Round(height * 0.163);
var targets = new List<NativeGridTarget>();
for (var row = 0; row < rows; row++)
{
for (var col = 0; col < cols; col++)
{
targets.Add(new NativeGridTarget(startX + col * stepX, startY + row * stepY, row, col));
}
}
return new NativeGrid
{
Rows = rows,
Cols = cols,
Targets = targets,
AnchorX = Math.Max(1, (int)Math.Round(width * 0.36)),
AnchorY = Math.Max(1, (int)Math.Round(height * 0.5)),
};
}
public object ToPayload() => new
{
rows = Rows,
cols = Cols,
count = Targets.Count,
anchorX = AnchorX,
anchorY = AnchorY,
first = Targets.FirstOrDefault()?.ToPayload(),
last = Targets.LastOrDefault()?.ToPayload(),
};
}
private sealed record NativeGridTarget(int X, int Y, int Row, int Col)
{
public object ToPayload() => new { x = X, y = Y, row = Row, col = Col };
}
}
internal static class Native