Improve IK-style artifact scanner pipeline
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
@@ -9,14 +11,20 @@ import type {
|
||||
ReviewSampleListResult,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
AppRuntimeInfo,
|
||||
} from "../src/types/global.js";
|
||||
import { validateLookupPackage } from "../src/lib/genshinLookup.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface DevControlServerDependencies {
|
||||
registeredHotkeys: Record<string, boolean>;
|
||||
appBuild: AppRuntimeInfo;
|
||||
hasMainWindow: () => boolean;
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
warmOcr: (engine: "current" | "ik-traineddata") => Promise<unknown>;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
@@ -25,6 +33,7 @@ interface DevControlServerDependencies {
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
requestShutdown?: (reason: string) => void;
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
@@ -136,13 +145,30 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/dev/shutdown") {
|
||||
if (!deps.requestShutdown) {
|
||||
writeDevJson(res, 501, { ok: false, error: "shutdown not supported" });
|
||||
return;
|
||||
}
|
||||
const reason = url.searchParams.get("reason") || "dev-control shutdown requested";
|
||||
writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason });
|
||||
setTimeout(() => deps.requestShutdown?.(reason), 50);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||
? { type: "start-auto", scanLimit: limit }
|
||||
const entry = url.searchParams.get("entry");
|
||||
const engine = url.searchParams.get("engine");
|
||||
const scanEntryMode = entry === "paimon-menu" || entry === "visible-inventory" || entry === "direct-inventory" || entry === "auto-entry"
|
||||
? entry
|
||||
: undefined;
|
||||
const ocrEngine = engine === "ik-traineddata" ? "ik-traineddata" : engine === "current" ? "current" : undefined;
|
||||
const hasLimit = Number.isFinite(limit) && limit > 0;
|
||||
const command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine
|
||||
? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
@@ -174,6 +200,154 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/ocr/warmup") {
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
deps.warmOcr(engine)
|
||||
.then((status) => writeDevJson(res, 200, { ok: true, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/status") {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/regenerate") {
|
||||
execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 })
|
||||
.then(({ stdout, stderr }) => {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr });
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/benchmark-ocr") {
|
||||
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const profileParam = url.searchParams.get("profile");
|
||||
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
|
||||
const engines: Array<"current" | "ik-traineddata"> = engineParam === "compare"
|
||||
? ["current", "ik-traineddata"]
|
||||
: engineParam === "ik-traineddata"
|
||||
? ["ik-traineddata"]
|
||||
: ["current"];
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
const benchmarkSource = source;
|
||||
|
||||
async function runEngineBenchmark(engine: "current" | "ik-traineddata") {
|
||||
const startedAt = Date.now();
|
||||
const captures: Array<{
|
||||
index: number;
|
||||
elapsedMs: number;
|
||||
ocrFields: number;
|
||||
timedOut: boolean;
|
||||
ocrSkipped: boolean;
|
||||
artifactDetailConfidence: number;
|
||||
sanctified: boolean;
|
||||
prepareMs: number;
|
||||
ocrMs: number;
|
||||
totalMs: number;
|
||||
ocrProfile?: "full" | "fast";
|
||||
ocrWorkerPoolSize?: number;
|
||||
ocrFieldMs?: Record<string, number>;
|
||||
}> = [];
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const captureStartedAt = Date.now();
|
||||
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile,
|
||||
ocrEngine: engine,
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
captures.push({
|
||||
index,
|
||||
elapsedMs: Date.now() - captureStartedAt,
|
||||
ocrFields: capture.ocr?.length ?? 0,
|
||||
timedOut: Boolean(capture.ocrTimedOut),
|
||||
ocrSkipped: Boolean(capture.ocrSkipped),
|
||||
artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0,
|
||||
sanctified: Boolean(capture.sanctified),
|
||||
prepareMs: capture.timings?.prepareMs ?? 0,
|
||||
ocrMs: capture.timings?.ocrMs ?? 0,
|
||||
totalMs: capture.timings?.totalMs ?? 0,
|
||||
ocrProfile: capture.timings?.ocrProfile,
|
||||
ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize,
|
||||
ocrFieldMs: capture.timings?.ocrFieldMs,
|
||||
});
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right);
|
||||
const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right);
|
||||
const averageMs = Math.round(elapsedMs / limit);
|
||||
const averageOcrMs = ocrTimings.length > 0
|
||||
? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length)
|
||||
: 0;
|
||||
const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0;
|
||||
const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0;
|
||||
const ocrFieldTotals = captures.reduce<Record<string, { totalMs: number; count: number; maxMs: number }>>((fields, capture) => {
|
||||
for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) {
|
||||
const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 };
|
||||
current.totalMs += elapsed;
|
||||
current.count += 1;
|
||||
current.maxMs = Math.max(current.maxMs, elapsed);
|
||||
fields[field] = current;
|
||||
}
|
||||
return fields;
|
||||
}, {});
|
||||
const ocrFieldAverages = Object.fromEntries(
|
||||
Object.entries(ocrFieldTotals).map(([field, timing]) => [
|
||||
field,
|
||||
{
|
||||
averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)),
|
||||
maxMs: timing.maxMs,
|
||||
count: timing.count,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
engine,
|
||||
nativeTesseract: "not-enabled",
|
||||
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
|
||||
ocrProfile,
|
||||
limit,
|
||||
elapsedMs,
|
||||
averageMs,
|
||||
averageOcrMs,
|
||||
minMs: timings[0] ?? 0,
|
||||
p50Ms: percentile(0.5),
|
||||
p90Ms: percentile(0.9),
|
||||
maxMs: timings[timings.length - 1] ?? 0,
|
||||
ocrP50Ms: ocrPercentile(0.5),
|
||||
ocrP90Ms: ocrPercentile(0.9),
|
||||
ocrFieldAverages,
|
||||
projectedMs: {
|
||||
artifacts20: averageMs * 20,
|
||||
artifacts45: averageMs * 45,
|
||||
artifacts100: averageMs * 100,
|
||||
},
|
||||
skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length,
|
||||
captures,
|
||||
};
|
||||
}
|
||||
const summaries = await Promise.all(engines.map((engine) => runEngineBenchmark(engine)));
|
||||
writeDevJson(res, 200, {
|
||||
ok: true,
|
||||
summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries },
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
|
||||
Reference in New Issue
Block a user