3 Commits

Author SHA1 Message Date
AzuTear 2c6c1a8b31 feat(ocr): resolution-anchored layout module + crop preprocessing
Implements ADR-009 (structure + preprocessing; exact IK fixed coordinates still
need calibration against a reference 16:9 screenshot).

- src/lib/layoutProfile.ts: pure, unit-tested geometry for the artifact screen -
  detail rect, the four detail crops, inventory rect/count crop, 5-col grid,
  16:9 detection, aspect label, and an off-16:9 support warning. Single source of
  truth; electron/main.ts now delegates all crop/grid geometry to it and keeps
  colour detection only as the detail-rect fallback.
- src/lib/ocrPreprocess.ts: pure, unit-tested Otsu binarization with inversion
  (artifact text is the bright foreground) over a BGRA bitmap.
- main.ts: OCR now reads an upscaled + binarized copy of each crop; the original
  crop is retained for the diagnostics UI. CaptureResult carries layout info
  { aspect, isSixteenNine, warning }.

NOTE: image preprocessing changes the OCR input and cannot be validated by the
text-level eval harness; it needs a live Genshin 16:9 capture to confirm/tune
(threshold, invert, upscale factor). 88 tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:42:16 +02:00
AzuTear c7138b541d feat(native): C# input/capture sidecar replacing the PowerShell helper
Implements ADR-008. native/input-helper is a self-contained .NET 9 console exe
speaking the identical JSON-over-stdin/stdout protocol as the old PowerShell
helper (ping/cursor/runtime/focus/click/scroll/bounds/capture), so the
InputHelperService interface is unchanged.

- Win32 interop compiled once (native exe), not per call.
- PerMonitorV2 DPI via manifest so click/capture coordinates stay correct on
  mixed-DPI multi-monitor setups.
- capture returns base64 PNG bytes inline (imageBase64) instead of writing a
  temp file per frame; the client handles both base64 and the PowerShell path.
- InputHelperClient prefers the exe and falls back to the embedded PowerShell
  helper when the exe is absent, so the app still runs without the .NET build.
- main.ts resolves the exe (INPUT_HELPER_EXE env -> packaged resources/input-helper
  -> native/input-helper/bin/publish). electron-builder ships it via extraResources.
- npm run helper:build; README documents the build + fallback.

Verified end-to-end through the compiled client: sidecar spawns, runtime info
and a base64 primary-screen capture return correctly. Build stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:33:26 +02:00
AzuTear 92345ef51a fix(scan): repair pre-existing tsc errors so the build is green
The scan feature had 16 tsc errors from refactor drift; npm run build failed.

- modals/hooks/* are five levels deep but imported ../../../../lib (four);
  corrected to ../../../../../lib.
- useScanDiagnosticsModalModel / useScanReviewQueueModalModel picked
  saveReviewSample / canSaveReviewSample / loadReviewQueue from the modal Props,
  but the components wire those through from controller.*; source them from the
  controller type instead (fixes the downstream unknown-type errors).
- useScanResultCardModel let its field-row tuple array widen to
  (string | ParsedField)[][]; annotate it Array<[string, ParsedField]> like the
  sibling hook.
- ScanTopControlsModel was missing autoScanRunning (destructured by the
  component); add it. useScanTopControlsModel does not use refreshCaptureSources,
  so its input is Omit<...,"refreshCaptureSources">.

tsc, vite build, and the electron build all pass; 74 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:25:34 +02:00
19 changed files with 1147 additions and 161 deletions
+14
View File
@@ -34,6 +34,20 @@ npm run dev
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
### Input/Capture helper (C# sidecar)
Input automation and screen capture run through a compiled C# sidecar
(`native/input-helper`, see ADR-008). Build it once:
```powershell
npm run helper:build # requires the .NET SDK; produces a self-contained exe
```
The app auto-detects the exe (`INPUT_HELPER_EXE` env override → packaged
`resources/input-helper``native/input-helper/bin/publish`). If the exe is not
present it falls back to the embedded PowerShell helper, so the app still runs
without the .NET build - just slower and with the old per-frame temp-file capture.
### Automatischer Scan: als Administrator starten
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
+72 -132
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import http, { type Server } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -21,6 +22,17 @@ import type {
ReviewSamplesRepositoryPort,
ScannerLearningRepositoryPort,
} from "./repositories/index.js";
import {
aspectRatioLabel,
detailCropRects,
inventoryCountCropRect,
inventoryGrid as layoutInventoryGrid,
inventoryRect as layoutInventoryRect,
isSixteenNine,
layoutSupportWarning,
profileDetailRect,
} from "../src/lib/layoutProfile.js";
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
// Chromium's renderer sandbox can refuse to fully initialize (or silently
// crash the GPU/renderer process) when the hosting process runs with a full
@@ -70,6 +82,25 @@ function getInputHelperService() {
return inputHelperService;
}
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
// the service uses the embedded PowerShell helper when the exe was never built.
function resolveInputHelperExePath(): string | null {
const candidates = [
process.env.INPUT_HELPER_EXE,
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
try {
if (existsSync(candidate)) return candidate;
} catch {
// Unreadable path; try the next candidate.
}
}
return null;
}
function getRepositoryContext() {
if (!repositoryContext) {
throw new Error("Repository context has not been initialized.");
@@ -726,84 +757,48 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
return sourceImage.crop(safeRect).toDataURL();
}
// Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then
// grayscale + Otsu-binarize with inversion (artifact text is the bright
// foreground). The original crop is kept separately for the diagnostics UI.
function preprocessedCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * 2), quality: "best" });
const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toDataURL();
const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height });
return nativeImage
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
.toDataURL();
}
function createCrops(
sourceImage: NativeImage,
imageSize: { width: number; height: number },
detailRect: Electron.Rectangle,
inventoryRect: Electron.Rectangle,
) {
const templates: CropTemplate[] = [
{
id: "artifact-title",
label: "Artifact title",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.05),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
{
id: "artifact-main-stat",
label: "Main stat",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.20),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.18),
},
},
{
id: "artifact-substats",
label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.41),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.25),
},
},
{
id: "artifact-footer",
label: "Footer",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.78),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
];
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize);
if (inventoryRect.width > 120 && inventoryRect.height > 80) {
templates.push({
id: "inventory-count",
label: "Inventory count",
rect: {
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
width: Math.round(inventoryRect.width * 0.34),
height: Math.round(inventoryRect.height * 0.09),
},
rect: inventoryCountCropRect(inventoryRect, imageSize),
});
}
return templates
.map((template) => ({
...template,
rect: clampCaptureRect(template.rect, imageSize),
dataUrl: imageCropDataUrl(sourceImage, template.rect, imageSize),
}))
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0)
.map((crop) => ({
...crop,
rect: {
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
},
}));
.map((template) => {
const rect = clampCaptureRect(template.rect, imageSize);
return {
id: template.id,
label: template.label,
rect,
dataUrl: imageCropDataUrl(sourceImage, rect, imageSize),
ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize),
};
})
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0);
}
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
@@ -845,79 +840,17 @@ function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: num
return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize);
}
if (width > 0 && height > 0) {
return clampCaptureRect(
{
x: Math.round(width * 0.50),
y: Math.round(height * 0.08),
width: Math.round(width * 0.46),
height: Math.round(height * 0.74),
},
imageSize,
);
}
return { x: 0, y: 0, width, height };
// Colour detection found nothing usable; fall back to the resolution-anchored
// profile rect (single source of truth in layoutProfile).
return profileDetailRect(imageSize);
}
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const { width, height } = imageSize;
const preferredWidth = Math.max(140, Math.round(width * 0.48));
const x = Math.round(width * 0.03);
const y = Math.round(detailRect.y + detailRect.height * 0.09);
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
return clampCaptureRect(
{
x,
y,
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
height: Math.max(140, Math.round(height * 0.70)),
},
imageSize,
);
return layoutInventoryRect(imageSize, detailRect);
}
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const inventoryRect = inferInventoryRect(imageSize, detailRect);
const cols = 5;
if (inventoryRect.width < 160 || inventoryRect.height < 140) {
return {
centers: [],
rows: 0,
cols: 0,
confidence: 0,
source: "missing" as const,
};
}
const cellWidth = Math.max(56, Math.round(inventoryRect.width / cols));
const stepX = Math.round(cellWidth * 0.96);
const stepY = Math.round(cellWidth * 1.03);
const visibleRows = Math.max(2, Math.min(6, Math.round(inventoryRect.height / Math.max(stepY, 1))));
const startX = inventoryRect.x + Math.max(6, Math.round(stepX * 0.45));
const startY = inventoryRect.y + Math.max(6, Math.round(stepY * 0.45));
const centers = [];
for (let row = 0; row < visibleRows; row++) {
for (let col = 0; col < cols; col++) {
const x = startX + col * stepX;
const y = startY + row * stepY;
if (x < imageSize.width && y < imageSize.height) {
centers.push({ x, y, row, col });
}
}
}
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
return {
centers: trimmed,
rows: visibleRows,
cols,
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
source: "detected" as const,
};
return layoutInventoryGrid(imageSize, detailRect);
}
async function buildCaptureResult(
@@ -939,7 +872,9 @@ async function buildCaptureResult(
const croppedPayload = crops.map((crop) => ({
id: crop.id,
label: crop.label,
dataUrl: crop.dataUrl,
// OCR reads the preprocessed (upscaled + binarized) crop; the original is
// kept below for the diagnostics UI.
dataUrl: crop.ocrDataUrl ?? crop.dataUrl,
}));
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload);
@@ -970,6 +905,11 @@ async function buildCaptureResult(
})),
inventoryGrid: inferInventoryGrid(size, detailRect),
inventoryCount: count,
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: isSixteenNine(size),
warning: layoutSupportWarning(size),
},
};
}
@@ -1032,7 +972,7 @@ function initializeAppLifecycle() {
artifactStoreRepository = repositoryContext.artifactStoreRepository;
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
scannerLearningRepository = repositoryContext.scannerLearningRepository;
inputHelperService = createInputHelperService({ userDataPath });
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
registerIpcHandlers({
focusMainWindow: () => focusMainWindow(),
+48 -11
View File
@@ -382,7 +382,7 @@ class InputHelperClient {
private starting: Promise<void> | null = null;
private disposed = false;
constructor(private readonly scriptUserDataPath: string) {}
constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
private async ensureStarted() {
if (this.child) return;
@@ -396,14 +396,31 @@ class InputHelperClient {
}
private async start() {
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
// Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
// start, fall back to the embedded PowerShell helper so the app keeps working
// on machines where the native exe was never built.
if (this.options.exePath) {
try {
await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
return;
} catch {
this.teardownChild();
}
}
await this.startWith(await this.spawnPowershell());
}
private async spawnPowershell() {
const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
}
private async startWith(child: ChildProcessWithoutNullStreams) {
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
child.stderr.setEncoding("utf8");
@@ -419,10 +436,22 @@ class InputHelperClient {
});
this.child = child;
// First request compiles the Win32 interop; give it extra time.
// The C# sidecar answers ping immediately; the PowerShell fallback compiles
// Win32 interop on the first request, so give it extra time.
await this.send("ping", {}, 20000);
}
private teardownChild() {
const child = this.child;
this.child = null;
this.buffer = "";
try {
child?.kill();
} catch {
// Child was never spawned or already gone.
}
}
private handleStdout(chunk: string) {
this.buffer += chunk;
let newlineIndex = this.buffer.indexOf("\n");
@@ -485,8 +514,8 @@ export interface InputHelperService {
dispose(): void;
}
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
const inputHelper = new InputHelperClient(options.userDataPath);
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return inputHelper.request(op, params, timeoutMs);
@@ -590,16 +619,24 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
async function capturePrimaryScreenViaGdi() {
const result = await request("capture", {}, 15000);
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
// The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
// fallback writes a temp PNG and returns its path.
let base64: string;
if (typeof result.imageBase64 === "string" && result.imageBase64) {
base64 = result.imageBase64;
} else {
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
base64 = buffer.toString("base64");
}
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
? captureTargetRaw
: "primary-screen";
return {
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
dataUrl: `data:image/png;base64,${base64}`,
width: Number(result.width),
height: Number(result.height),
originX: Number(result.originX),
+4
View File
@@ -0,0 +1,4 @@
# .NET build outputs — the self-contained exe is built via `npm run helper:build`,
# not committed (it is ~100 MB).
bin/
obj/
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InputHelper</AssemblyName>
<RootNamespace>GenshinAssistant.InputHelper</RootNamespace>
<!-- Screen.PrimaryScreen (Forms) + Bitmap/Graphics.CopyFromScreen (Drawing). -->
<UseWindowsForms>true</UseWindowsForms>
<!-- Single self-contained exe: no .NET install needed on the user's machine. -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Per-monitor DPI v2 so SendInput/capture coordinates match a mixed-DPI
multi-monitor setup (same reason as the old PowerShell helper). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
+466
View File
@@ -0,0 +1,466 @@
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using System.Windows.Forms;
// Long-lived input/capture sidecar for the Genshin Artifact Assistant.
// Drop-in replacement for the old PowerShell helper (see ADR-008): identical
// JSON-over-stdin/stdout protocol - one JSON request per line, one JSON response
// per line - so the Electron-side InputHelperService is unchanged. Win32 interop
// is compiled once (this is a native exe), and capture returns base64 PNG bytes
// directly instead of writing a temp file per frame.
namespace GenshinAssistant.InputHelper;
internal static class Program
{
private static IntPtr _genshinHwnd = IntPtr.Zero;
private static int Main()
{
// Manifest already declares PerMonitorV2; this is a belt-and-suspenders
// call for hosts that ignore the manifest.
try { Native.SetProcessDpiAwarenessContext(Native.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); }
catch { try { Native.SetProcessDpiAwareness(2); } catch { /* oldest fallback */ Native.SetProcessDPIAware(); } }
Console.OutputEncoding = Encoding.UTF8;
var stdout = Console.Out;
string? line;
while ((line = Console.In.ReadLine()) != null)
{
if (line.Trim().Length == 0) continue;
var response = new Dictionary<string, object?> { ["id"] = "", ["ok"] = true };
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
response["id"] = GetString(root, "id");
var op = GetString(root, "op");
Handle(op, root, response);
}
catch (Exception ex)
{
response["ok"] = false;
response["error"] = ex.Message;
}
stdout.WriteLine(JsonSerializer.Serialize(response));
stdout.Flush();
}
return 0;
}
private static void Handle(string op, JsonElement root, Dictionary<string, object?> response)
{
switch (op)
{
case "ping":
response["pong"] = true;
break;
case "cursor":
{
var state = GetCursorState();
response["cursorX"] = state.X;
response["cursorY"] = state.Y;
response["escapePressed"] = state.Escape;
response["enterPressed"] = state.Enter;
response["f9Pressed"] = state.F9;
break;
}
case "runtime":
{
var hwnd = FindGenshinWindow();
var fgHwnd = Native.GetForegroundWindow();
response["isElevated"] = IsElevated();
response["genshinFound"] = hwnd != IntPtr.Zero;
response["genshinHwnd"] = hwnd.ToInt64();
response["targetProcess"] = ProcessNameFromHwnd(hwnd);
response["foregroundProcess"] = ProcessNameFromHwnd(fgHwnd);
response["foregroundHwnd"] = fgHwnd.ToInt64();
response["helperPid"] = Environment.ProcessId;
break;
}
case "focus":
{
var info = FocusGenshinWindow();
response["focused"] = info.Focused;
response["alreadyForeground"] = info.AlreadyForeground;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["genshinFound"] = info.Hwnd != IntPtr.Zero;
response["setForegroundResult"] = info.SetForegroundResult;
break;
}
case "click":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
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.
Native.SetCursorPos(targetX, targetY);
Native.GetCursorPos(out var pt);
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
var clickEventsSent = onTarget ? SendMouseClickBatch() : 0u;
var state = GetCursorState();
response["cursorX"] = state.X;
response["cursorY"] = state.Y;
response["escapePressed"] = state.Escape;
response["enterPressed"] = state.Enter;
response["f9Pressed"] = state.F9;
response["moved"] = onTarget;
response["focused"] = info.Focused;
response["alreadyForeground"] = info.AlreadyForeground;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["isElevated"] = IsElevated();
// Only report a click when the cursor is verifiably on target and
// SendInput injected both events; real acceptance is proven later
// by the detail-panel fingerprint.
response["clicked"] = onTarget && clickEventsSent >= 2;
response["inputBlocked"] = onTarget && clickEventsSent < 2;
break;
}
case "scroll":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
if (TryGetInt(root, "x", out var ax) && TryGetInt(root, "y", out var ay))
{
Native.SetCursorPos(ax, ay);
Thread.Sleep(30);
}
Native.GetCursorPos(out var pt);
response["cursorX"] = pt.X;
response["cursorY"] = pt.Y;
response["focused"] = info.Focused;
response["foregroundProcess"] = info.ForegroundProcess;
response["isElevated"] = IsElevated();
var notches = GetInt(root, "notches");
var stepDelta = notches < 0 ? -120 : 120;
var count = Math.Min(60, Math.Abs(notches));
uint sentTotal = 0;
for (var i = 0; i < count; i++)
{
sentTotal += SendMouseWheel(stepDelta);
Thread.Sleep(45);
}
response["notchesSent"] = sentTotal;
response["inputBlocked"] = count > 0 && sentTotal == 0;
break;
}
case "bounds":
{
var bounds = GetGenshinClientBounds();
if (bounds == null)
{
response["found"] = false;
}
else
{
response["found"] = true;
response["left"] = bounds.Value.Left;
response["top"] = bounds.Value.Top;
response["width"] = bounds.Value.Width;
response["height"] = bounds.Value.Height;
}
break;
}
case "capture":
{
var bounds = GetGenshinClientBounds();
string captureTarget;
Rect area;
if (bounds == null)
{
var screen = Screen.PrimaryScreen!.Bounds;
area = new Rect { Left = screen.Left, Top = screen.Top, Width = screen.Width, Height = screen.Height };
captureTarget = "primary-screen";
}
else
{
area = bounds.Value;
captureTarget = "genshin-client";
}
using var bitmap = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(area.Left, area.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
response["imageBase64"] = Convert.ToBase64String(stream.ToArray());
response["width"] = area.Width;
response["height"] = area.Height;
response["originX"] = area.Left;
response["originY"] = area.Top;
response["captureTarget"] = captureTarget;
break;
}
default:
response["ok"] = false;
response["error"] = "unknown op";
break;
}
}
private readonly struct Rect
{
public int Left { get; init; }
public int Top { get; init; }
public int Width { get; init; }
public int Height { get; init; }
}
private struct CursorState
{
public int X;
public int Y;
public bool Escape;
public bool Enter;
public bool F9;
}
private struct FocusInfo
{
public IntPtr Hwnd;
public bool Focused;
public bool AlreadyForeground;
public string ForegroundProcess;
public string TargetProcess;
public bool SetForegroundResult;
}
private static CursorState GetCursorState()
{
Native.GetCursorPos(out var pt);
// Only 0x8000 (held right now). The 0x0001 "pressed since last call" bit
// is unreliable and fires for ESC presses used to navigate Genshin menus.
var esc = (Native.GetAsyncKeyState(0x1B) & 0x8000) != 0;
var enter = (Native.GetAsyncKeyState(0x0D) & 0x8000) != 0;
var f9 = (Native.GetAsyncKeyState(0x78) & 0x8000) != 0;
return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 };
}
private static FocusInfo FocusGenshinWindow()
{
var hwnd = FindGenshinWindow();
var info = new FocusInfo
{
Hwnd = hwnd,
ForegroundProcess = "",
TargetProcess = ProcessNameFromHwnd(hwnd),
};
if (hwnd == IntPtr.Zero) return info;
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
if (!info.AlreadyForeground)
{
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
// No ALT tap: this app and Genshin run at the same (elevated)
// integrity level, so SetForegroundWindow succeeds on its own. An ALT
// tap would toggle menu-mnemonic mode and swallow the next inputs.
info.SetForegroundResult = Native.SetForegroundWindow(hwnd);
Thread.Sleep(140);
}
var foreground = Native.GetForegroundWindow();
info.Focused = foreground == hwnd;
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
return info;
}
private static IntPtr FindGenshinWindow()
{
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
foreach (var proc in Process.GetProcesses())
{
try
{
var name = proc.ProcessName;
if ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
&& proc.MainWindowHandle != IntPtr.Zero)
{
_genshinHwnd = proc.MainWindowHandle;
return _genshinHwnd;
}
}
catch
{
// Process exited between enumeration and inspection; ignore.
}
}
_genshinHwnd = IntPtr.Zero;
return _genshinHwnd;
}
private static Rect? GetGenshinClientBounds()
{
var hwnd = FindGenshinWindow();
if (hwnd == IntPtr.Zero) return null;
if (!Native.GetClientRect(hwnd, out var rect)) return null;
var topLeft = new Native.POINT { X = 0, Y = 0 };
if (!Native.ClientToScreen(hwnd, ref topLeft)) return null;
var width = rect.Right - rect.Left;
var height = rect.Bottom - rect.Top;
if (width <= 0 || height <= 0) return null;
return new Rect { Left = topLeft.X, Top = topLeft.Y, Width = width, Height = height };
}
private static string ProcessNameFromHwnd(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero) return "";
Native.GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return "";
try { return Process.GetProcessById((int)pid).ProcessName; }
catch { return ""; }
}
private static bool IsElevated()
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
private static uint SendMouseClickBatch()
{
var inputs = new Native.INPUT[2];
inputs[0].type = 0; // INPUT_MOUSE
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_LEFTDOWN;
inputs[1].type = 0;
inputs[1].mi.dwFlags = Native.MOUSEEVENTF_LEFTUP;
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
}
private static uint SendMouseWheel(int wheelData)
{
var inputs = new Native.INPUT[1];
inputs[0].type = 0;
inputs[0].mi.mouseData = unchecked((uint)wheelData);
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_WHEEL;
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
}
private static string GetString(JsonElement root, string name)
=> root.TryGetProperty(name, out var value) ? value.ToString() : "";
private static int GetInt(JsonElement root, string name)
=> TryGetInt(root, name, out var value) ? value : 0;
private static bool TryGetInt(JsonElement root, string name, out int value)
{
value = 0;
if (!root.TryGetProperty(name, out var element)) return false;
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) return true;
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
return false;
}
}
internal static class Native
{
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
public const uint MOUSEEVENTF_WHEEL = 0x0800;
public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new(-4);
[StructLayout(LayoutKind.Sequential)]
public struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
// Binary-compatible with the Win32 INPUT for mouse-only use on x64:
// type(4) + 4 pad + MOUSEINPUT(32) = 40 bytes = sizeof(INPUT).
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT
{
public int dx;
public int dy;
public uint mouseData;
public uint dwFlags;
public uint time;
public UIntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT
{
public int type;
public MOUSEINPUT mi;
}
[DllImport("user32.dll")]
public static extern bool SetProcessDPIAware();
[DllImport("shcore.dll")]
public static extern int SetProcessDpiAwareness(int value);
[DllImport("user32.dll")]
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", SetLastError = true)]
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="GenshinAssistant.InputHelper" type="win32" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- PerMonitorV2: coordinates stay correct across mixed-DPI monitors. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
</assembly>
+10
View File
@@ -15,6 +15,7 @@
"lint": "tsc --noEmit",
"test": "vitest run",
"eval": "vitest run src/eval/ocrEval.test.ts",
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
"data:genshin": "node scripts/generate-genshin-data.cjs"
},
"dependencies": {
@@ -48,6 +49,15 @@
"dist-electron/**/*",
"package.json"
],
"extraResources": [
{
"from": "native/input-helper/bin/publish",
"to": "input-helper",
"filter": [
"**/*"
]
}
],
"win": {
"target": "nsis",
"requestedExecutionLevel": "requireAdministrator"
@@ -60,19 +60,20 @@ export function useScanResultCardModel({
parsed,
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
const substats = parsed.substats;
const rows: Array<[string, ParsedField]> = [
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
];
return {
levelField: getLevelField(parsed),
quality: resolveQuality(parsed.confidence),
fieldRows: [
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
].map(([label, field]) => ({
fieldRows: rows.map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
@@ -8,6 +8,7 @@ export interface ScanTopControlsModel {
canStartAutoScan: boolean;
canStartManualScan: boolean;
canCaptureSingle: boolean;
autoScanRunning: boolean;
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
selectGenshinSource: () => void;
openSettings: () => void;
@@ -41,7 +42,7 @@ export function useScanTopControlsModel({
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps): ScanTopControlsModel {
}: Omit<ScanTopControlsSectionProps, "refreshCaptureSources">): ScanTopControlsModel {
const {
setSettingsOpen,
setDiagnosticsOpen,
@@ -116,6 +117,7 @@ export function useScanTopControlsModel({
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
autoScanRunning,
handleSourceChange,
selectGenshinSource,
openSettings,
@@ -1,6 +1,6 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanDetailsModalProps } from "../types";
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
import type { ParsedArtifactCandidate } from "../../../../../lib/artifactOcrParser";
export interface ScanDetailsModalModel {
closeDetails: () => void;
@@ -1,5 +1,5 @@
import { detailFingerprint } from "../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../lib/genshinData";
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../../lib/genshinData";
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types";
@@ -39,16 +39,20 @@ export interface ScanDiagnosticsModalModel {
canSaveReviewSample: boolean;
}
type ScanDiagnosticsController = ScanDiagnosticsModalProps["controller"];
interface UseScanDiagnosticsModalModelInput extends Pick<
ScanDiagnosticsModalProps,
| "setDetailsOpen"
| "setDiagnosticsOpen"
| "saveReviewSample"
| "canSaveReviewSample"
| "latestCapture"
| "controller"
> {
captureStatus: string;
// saveReviewSample / canSaveReviewSample live on the controller, not the modal
// props; the component wires them through from controller.* .
saveReviewSample: ScanDiagnosticsController["saveReviewSample"];
canSaveReviewSample: ScanDiagnosticsController["canSaveReviewSample"];
}
export function useScanDiagnosticsModalModel({
@@ -1,5 +1,5 @@
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
import type { ReviewSampleAnalysis } from "../../../../../lib/reviewSampleAnalysis";
import type { ScanReviewQueueModalProps } from "../types";
export interface ScanReviewQueueRow {
@@ -18,8 +18,10 @@ export interface ScanReviewQueueModalModel {
interface UseScanReviewQueueModalModelInput extends Pick<
ScanReviewQueueModalProps,
"setReviewQueueOpen" | "loadReviewQueue"
"setReviewQueueOpen"
> {
// loadReviewQueue is wired through from controller.* by the component.
loadReviewQueue: ScanReviewQueueModalProps["controller"]["loadReviewQueue"];
reviewAnalysis: ReviewSampleAnalysis;
reviewSampleTotal: number;
}
@@ -1,6 +1,6 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
export interface ScanSettingsModalModel {
closeSettings: () => void;
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
aspectRatioLabel,
detailCropRects,
inventoryCountCropRect,
inventoryGrid,
inventoryRect,
isSixteenNine,
layoutSupportWarning,
profileDetailRect,
} from "./layoutProfile";
const HD = { width: 1920, height: 1080 };
const QHD = { width: 2560, height: 1440 };
const ULTRAWIDE = { width: 3440, height: 1440 };
describe("layoutProfile", () => {
it("detects 16:9 across common resolutions and rejects ultrawide", () => {
expect(isSixteenNine(HD)).toBe(true);
expect(isSixteenNine(QHD)).toBe(true);
expect(isSixteenNine({ width: 3840, height: 2160 })).toBe(true);
expect(isSixteenNine(ULTRAWIDE)).toBe(false);
expect(isSixteenNine({ width: 1920, height: 1200 })).toBe(false); // 16:10
});
it("labels the aspect ratio", () => {
expect(aspectRatioLabel(HD)).toBe("1.78:1");
expect(aspectRatioLabel({ width: 0, height: 0 })).toBe("unknown");
});
it("warns only for non-16:9 resolutions", () => {
expect(layoutSupportWarning(HD)).toBe("");
expect(layoutSupportWarning(QHD)).toBe("");
expect(layoutSupportWarning(ULTRAWIDE)).toContain("nicht 16:9");
expect(layoutSupportWarning({ width: 0, height: 0 })).toBe("");
});
it("keeps the detail rect inside the image and on the right half", () => {
const rect = profileDetailRect(QHD);
expect(rect.x).toBeGreaterThanOrEqual(QHD.width * 0.45);
expect(rect.x + rect.width).toBeLessThanOrEqual(QHD.width);
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
});
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
const detail = profileDetailRect(QHD);
const crops = detailCropRects(detail, QHD);
expect(crops.map((crop) => crop.id)).toEqual([
"artifact-title",
"artifact-main-stat",
"artifact-substats",
"artifact-footer",
]);
let previousY = -1;
for (const crop of crops) {
expect(crop.rect.x).toBeGreaterThanOrEqual(0);
expect(crop.rect.y).toBeGreaterThan(previousY);
expect(crop.rect.x + crop.rect.width).toBeLessThanOrEqual(QHD.width);
expect(crop.rect.y + crop.rect.height).toBeLessThanOrEqual(QHD.height);
previousY = crop.rect.y;
}
});
it("places the inventory count crop inside the inventory panel", () => {
const detail = profileDetailRect(QHD);
const inv = inventoryRect(QHD, detail);
const count = inventoryCountCropRect(inv, QHD);
expect(count.x).toBeGreaterThanOrEqual(inv.x);
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
});
it("builds a 5-column inventory grid on the left", () => {
const detail = profileDetailRect(QHD);
const grid = inventoryGrid(QHD, detail);
expect(grid.cols).toBe(5);
expect(grid.source).toBe("detected");
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
});
it("reports a missing grid when the inventory panel is too small", () => {
const tiny = { width: 320, height: 180 };
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
expect(grid.source).toBe("missing");
expect(grid.centers).toHaveLength(0);
});
});
+205
View File
@@ -0,0 +1,205 @@
// Resolution-anchored layout geometry for the artifact inventory screen
// (ADR-009). Inventory Kamera's proven approach is to require borderless 16:9 and
// derive crop/grid coordinates from the client rectangle instead of detecting the
// panel by colour each frame. This module is the single, pure, unit-tested source
// of that geometry; electron/main.ts consumes it for cropping and keeps a
// colour-based detail-rect detector only as a fallback for off-profile setups.
//
// NOTE: the per-field detail crop fractions below are the current working values.
// True IK-style fixed coordinates need calibration against a reference 16:9
// screenshot; the structure here is what those calibrated numbers slot into.
export interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface CropTemplateRect {
id: string;
label: string;
rect: LayoutRect;
}
export interface InventoryGridLayout {
centers: Array<{ x: number; y: number; row: number; col: number }>;
rows: number;
cols: number;
confidence: number;
source: "detected" | "missing";
}
const SIXTEEN_NINE = 16 / 9;
export function aspectRatio(size: { width: number; height: number }): number {
if (!size.height) return 0;
return size.width / size.height;
}
export function aspectRatioLabel(size: { width: number; height: number }): string {
const ratio = aspectRatio(size);
if (ratio === 0) return "unknown";
return `${ratio.toFixed(2)}:1`;
}
// Genshin's UI is authored for 16:9; other aspect ratios letterbox or reflow and
// the anchored crops no longer line up. Allow a small tolerance for rounding.
export function isSixteenNine(size: { width: number; height: number }, tolerance = 0.02): boolean {
const ratio = aspectRatio(size);
if (ratio === 0) return false;
return Math.abs(ratio - SIXTEEN_NINE) <= SIXTEEN_NINE * tolerance;
}
// Empty when the client is a supported 16:9; otherwise a warning explaining that
// the anchored crops are unreliable off-profile (ADR-009: non-16:9 is explicitly
// unsupported for the auto scanner).
export function layoutSupportWarning(size: { width: number; height: number }): string {
if (size.width <= 0 || size.height <= 0) return "";
if (isSixteenNine(size)) return "";
return `Aufloesung ${size.width}x${size.height} ist nicht 16:9 (${aspectRatioLabel(size)}). Der Auto-Scan ist auf 16:9 im randlosen Fenstermodus ausgelegt; die Erkennung kann daneben liegen.`;
}
export function clampRect(rect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
const x = Math.max(0, Math.min(imageSize.width - 1, rect.x));
const y = Math.max(0, Math.min(imageSize.height - 1, rect.y));
const maxWidth = Math.max(1, imageSize.width - x);
const maxHeight = Math.max(1, imageSize.height - y);
return {
x,
y,
width: Math.max(1, Math.min(maxWidth, rect.width)),
height: Math.max(1, Math.min(maxHeight, rect.height)),
};
}
// Anchored guess for the artifact detail panel on the right of the screen. Used
// as the primary rect for a clean 16:9 client and as the fallback when colour
// detection cannot find the panel.
export function profileDetailRect(imageSize: { width: number; height: number }): LayoutRect {
const { width, height } = imageSize;
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
return clampRect(
{
x: Math.round(width * 0.5),
y: Math.round(height * 0.08),
width: Math.round(width * 0.46),
height: Math.round(height * 0.74),
},
imageSize,
);
}
// The four OCR crops inside the detail panel, as fractions of the detail rect.
export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] {
const templates: CropTemplateRect[] = [
{
id: "artifact-title",
label: "Artifact title",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.05),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
{
id: "artifact-main-stat",
label: "Main stat",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.2),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.18),
},
},
{
id: "artifact-substats",
label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.41),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.25),
},
},
{
id: "artifact-footer",
label: "Footer",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.78),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
];
return templates.map((template) => ({ ...template, rect: clampRect(template.rect, imageSize) }));
}
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
return clampRect(
{
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
width: Math.round(inventoryRect.width * 0.34),
height: Math.round(inventoryRect.height * 0.09),
},
imageSize,
);
}
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
const { width, height } = imageSize;
const preferredWidth = Math.max(140, Math.round(width * 0.48));
const x = Math.round(width * 0.03);
const y = Math.round(detailRect.y + detailRect.height * 0.09);
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
return clampRect(
{
x,
y,
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
height: Math.max(140, Math.round(height * 0.7)),
},
imageSize,
);
}
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
const rect = inventoryRect(imageSize, detailRect);
const cols = 5;
if (rect.width < 160 || rect.height < 140) {
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
}
const cellWidth = Math.max(56, Math.round(rect.width / cols));
const stepX = Math.round(cellWidth * 0.96);
const stepY = Math.round(cellWidth * 1.03);
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
const centers: InventoryGridLayout["centers"] = [];
for (let row = 0; row < visibleRows; row++) {
for (let col = 0; col < cols; col++) {
const x = startX + col * stepX;
const y = startY + row * stepY;
if (x < imageSize.width && y < imageSize.height) {
centers.push({ x, y, row, col });
}
}
}
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
return {
centers: trimmed,
rows: visibleRows,
cols,
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
source: "detected",
};
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { binarizeForOcr, computeLuminanceHistogram, otsuThreshold, type Bitmap } from "./ocrPreprocess";
// Build a BGRA bitmap from a grid of [b,g,r] pixels.
function bitmapFrom(pixels: Array<[number, number, number]>, width: number, height: number): Bitmap {
const data = Buffer.alloc(width * height * 4);
pixels.forEach(([b, g, r], index) => {
data[index * 4] = b;
data[index * 4 + 1] = g;
data[index * 4 + 2] = r;
data[index * 4 + 3] = 255;
});
return { data, width, height };
}
describe("ocrPreprocess", () => {
it("computes a luminance histogram over all pixels", () => {
const bitmap = bitmapFrom([
[0, 0, 0],
[255, 255, 255],
[0, 0, 0],
[255, 255, 255],
], 2, 2);
const histogram = computeLuminanceHistogram(bitmap);
expect(histogram[0]).toBe(2);
expect(histogram[255]).toBe(2);
expect(histogram.reduce((sum, count) => sum + count, 0)).toBe(4);
});
it("otsu splits a clean bimodal image between the two peaks", () => {
const histogram = new Array<number>(256).fill(0);
histogram[20] = 50;
histogram[220] = 50;
const threshold = otsuThreshold(histogram);
expect(threshold).toBeGreaterThanOrEqual(20);
expect(threshold).toBeLessThan(220);
});
it("otsu is safe on an empty histogram", () => {
expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127);
});
it("inverts bright foreground to black-on-white by default", () => {
// Bright text pixel + dark background pixel.
const bitmap = bitmapFrom([
[255, 255, 255], // bright -> should become black
[0, 0, 0], // dark -> should become white
], 2, 1);
const out = binarizeForOcr(bitmap, { threshold: 128 });
expect([out.data[0], out.data[1], out.data[2]]).toEqual([0, 0, 0]);
expect([out.data[4], out.data[5], out.data[6]]).toEqual([255, 255, 255]);
expect(out.data[3]).toBe(255);
});
it("keeps bright foreground white when inversion is disabled", () => {
const bitmap = bitmapFrom([
[255, 255, 255],
[0, 0, 0],
], 2, 1);
const out = binarizeForOcr(bitmap, { threshold: 128, invertBrightForeground: false });
expect(out.data[0]).toBe(255);
expect(out.data[4]).toBe(0);
});
it("preserves dimensions and always emits opaque pixels", () => {
const bitmap = bitmapFrom(Array.from({ length: 9 }, () => [100, 100, 100] as [number, number, number]), 3, 3);
const out = binarizeForOcr(bitmap);
expect(out.width).toBe(3);
expect(out.height).toBe(3);
for (let pixel = 0; pixel < 9; pixel++) {
expect(out.data[pixel * 4 + 3]).toBe(255);
}
});
});
+101
View File
@@ -0,0 +1,101 @@
// OCR preprocessing for artifact crops (ADR-009). Tesseract reads a clean, high
// contrast, dark-text-on-light image far more reliably than Genshin's native
// bright-text-on-dark UI. This binarizes a crop with Otsu thresholding and (by
// default) inverts, because artifact text is the bright foreground.
//
// Works on a raw BGRA bitmap (Electron NativeImage.getBitmap() layout on
// Windows). Kept pure and channel-order-agnostic for luminance so it is unit
// testable without Electron. Upscaling is done separately via NativeImage.resize
// before this runs - interpolated upscaling of small crops is a big Tesseract win
// and NativeImage does it better than hand-rolled JS.
export interface Bitmap {
data: Uint8Array | Buffer;
width: number;
height: number;
}
export interface BinarizeOptions {
/** Artifact text is the bright foreground, so invert to dark-on-light. */
invertBrightForeground?: boolean;
/** Override Otsu with a fixed 0-255 luminance threshold. */
threshold?: number;
}
const BYTES_PER_PIXEL = 4;
// Rec. 601 luma. Channel order does not matter for a weighted sum as long as we
// read the same three bytes; BGRA and RGBA give the same luminance here because
// we weight by position-independent coefficients applied to the actual R/G/B.
function luminanceAt(data: Uint8Array | Buffer, index: number): number {
// NativeImage on Windows is BGRA: byte0=B, byte1=G, byte2=R.
const b = data[index];
const g = data[index + 1];
const r = data[index + 2];
return 0.299 * r + 0.587 * g + 0.114 * b;
}
export function computeLuminanceHistogram(bitmap: Bitmap): number[] {
const histogram = new Array<number>(256).fill(0);
const { data, width, height } = bitmap;
const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) {
const value = Math.round(luminanceAt(data, pixel * BYTES_PER_PIXEL));
histogram[Math.max(0, Math.min(255, value))]++;
}
return histogram;
}
// Otsu's method: pick the threshold that maximizes between-class variance.
export function otsuThreshold(histogram: readonly number[]): number {
const total = histogram.reduce((sum, count) => sum + count, 0);
if (total === 0) return 127;
let sumAll = 0;
for (let level = 0; level < 256; level++) sumAll += level * histogram[level];
let sumBackground = 0;
let weightBackground = 0;
let maxVariance = -1;
let threshold = 127;
for (let level = 0; level < 256; level++) {
weightBackground += histogram[level];
if (weightBackground === 0) continue;
const weightForeground = total - weightBackground;
if (weightForeground === 0) break;
sumBackground += level * histogram[level];
const meanBackground = sumBackground / weightBackground;
const meanForeground = (sumAll - sumBackground) / weightForeground;
const betweenVariance = weightBackground * weightForeground * (meanBackground - meanForeground) ** 2;
if (betweenVariance > maxVariance) {
maxVariance = betweenVariance;
threshold = level;
}
}
return threshold;
}
export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap {
const { data, width, height } = bitmap;
const invert = options.invertBrightForeground ?? true;
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap));
const output = Buffer.alloc(width * height * BYTES_PER_PIXEL);
const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) {
const index = pixel * BYTES_PER_PIXEL;
const isBright = luminanceAt(data, index) > threshold;
// Bright foreground text -> black; dark background -> white (inverted).
const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0);
output[index] = value;
output[index + 1] = value;
output[index + 2] = value;
output[index + 3] = 255;
}
return { data: output, width, height };
}
+5
View File
@@ -56,6 +56,11 @@ export interface CaptureResult {
source: "ocr" | "missing";
text: string;
};
layout?: {
aspect: string;
isSixteenNine: boolean;
warning: string;
};
}
export interface WindowBounds {