finished up grid detection.
This commit is contained in:
parent
41d174195e
commit
1246884be9
11 changed files with 1037 additions and 19 deletions
BIN
assets/empty35.png
Normal file
BIN
assets/empty35.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/empty70.png
Normal file
BIN
assets/empty70.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
|
|||
import { logger } from '../util/logger.js';
|
||||
import type { BotController } from './BotController.js';
|
||||
import type { ScreenReader } from '../game/ScreenReader.js';
|
||||
import { GRID_LAYOUTS } from '../game/GridReader.js';
|
||||
import type { GameController } from '../game/GameController.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -150,6 +151,81 @@ export class DashboardServer {
|
|||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/hideout', async (_req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
try {
|
||||
await this.debug.gameController.focusGame();
|
||||
await this.debug.gameController.goToHideout();
|
||||
this.broadcastLog('info', 'Sent /hideout command');
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug hideout failed');
|
||||
res.status(500).json({ error: 'Hideout command failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Click first text, then wait for second text to appear and click it
|
||||
this.app.post('/api/debug/click-then-click', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number };
|
||||
if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; }
|
||||
try {
|
||||
// Click the first target
|
||||
const pos1 = await this.debug.screenReader.findTextOnScreen(first);
|
||||
if (!pos1) {
|
||||
this.broadcastLog('warn', `"${first}" not found on screen`);
|
||||
res.json({ ok: true, found: false, step: 'first' });
|
||||
return;
|
||||
}
|
||||
await this.debug.gameController.focusGame();
|
||||
await this.debug.gameController.leftClickAt(pos1.x, pos1.y);
|
||||
this.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`);
|
||||
|
||||
// Poll OCR until second text appears
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const pos2 = await this.debug.screenReader.findTextOnScreen(second);
|
||||
if (pos2) {
|
||||
await this.debug.gameController.leftClickAt(pos2.x, pos2.y);
|
||||
this.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`);
|
||||
res.json({ ok: true, found: true, position: pos2 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`);
|
||||
res.json({ ok: true, found: false, step: 'second' });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug click-then-click failed');
|
||||
res.status(500).json({ error: 'Click-then-click failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Grid scan with calibrated positions
|
||||
this.app.post('/api/debug/grid-scan', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { layout: layoutName } = req.body as { layout: string };
|
||||
if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; }
|
||||
try {
|
||||
const result = await this.debug.screenReader.grid.scan(layoutName);
|
||||
const imageBuffer = await this.debug.screenReader.captureRegion(result.layout.region);
|
||||
const imageBase64 = imageBuffer.toString('base64');
|
||||
const r = result.layout.region;
|
||||
this.broadcastLog('info',
|
||||
`Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells`);
|
||||
res.json({
|
||||
ok: true,
|
||||
occupied: result.occupied,
|
||||
cols: result.layout.cols,
|
||||
rows: result.layout.rows,
|
||||
image: imageBase64,
|
||||
region: result.layout.region,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug grid-scan failed');
|
||||
res.status(500).json({ error: 'Grid scan failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/find-and-click', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { text } = req.body as { text: string };
|
||||
|
|
|
|||
|
|
@ -274,10 +274,48 @@
|
|||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.debug-result:empty { display: none; }
|
||||
.grid-debug {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.grid-debug img {
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.grid-view {
|
||||
display: inline-grid;
|
||||
gap: 1px;
|
||||
background: #30363d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.grid-cell {
|
||||
width: 12px; height: 12px;
|
||||
background: #0d1117;
|
||||
}
|
||||
.grid-cell.occupied {
|
||||
background: #238636;
|
||||
}
|
||||
|
||||
.detect-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detect-badge.ok { background: #238636; color: #fff; }
|
||||
.detect-badge.fallback { background: #9e6a03; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -339,6 +377,25 @@
|
|||
<div class="debug-row">
|
||||
<button onclick="debugScreenshot()">Screenshot</button>
|
||||
<button onclick="debugOcr()">OCR Screen</button>
|
||||
<button onclick="debugHideout()">Go Hideout</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<button onclick="debugFindAndClick('ANGE')">ANGE</button>
|
||||
<button onclick="debugFindAndClick('STASH')">STASH</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<button onclick="debugAngeOption('Currency')">Currency Exchange</button>
|
||||
<button onclick="debugAngeOption('Manage')">Manage Shop</button>
|
||||
<button onclick="debugAngeOption('Buy')">Buy or Sell</button>
|
||||
<button onclick="debugAngeOption('Purchase')">Purchase Items</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<button onclick="debugGridScan('inventory')">Scan Inventory</button>
|
||||
<button onclick="debugGridScan('stash12')">Scan Stash 12×12</button>
|
||||
<button onclick="debugGridScan('stash24')">Scan Stash 24×24</button>
|
||||
<button onclick="debugGridScan('seller')">Scan Seller</button>
|
||||
<button onclick="debugGridScan('shop')">Scan Shop</button>
|
||||
<button onclick="debugGridScan('vendor')">Scan Vendor</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<input type="text" id="debugTextInput" placeholder="Text to find (e.g. Stash, Ange)" />
|
||||
|
|
@ -588,6 +645,63 @@
|
|||
}
|
||||
|
||||
// Debug functions
|
||||
async function debugGridScan(layout) {
|
||||
showDebugResult(`Scanning ${layout}...`);
|
||||
const res = await fetch('/api/debug/grid-scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ layout }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; }
|
||||
const el = document.getElementById('debugResult');
|
||||
const count = data.occupied.length;
|
||||
const r = data.region;
|
||||
let html = `<b>${layout}</b> ${data.cols}x${data.rows}`;
|
||||
html += ` — ${count} occupied cell(s)`;
|
||||
if (r) html += `<br><span style="color:#484f58">Region: (${r.x}, ${r.y}) ${r.width}x${r.height}</span>`;
|
||||
if (count > 0) {
|
||||
html += `<br>` + data.occupied.map(c => `(${c.row},${c.col})`).join(' ');
|
||||
}
|
||||
html += '<div class="grid-debug">';
|
||||
if (data.image) {
|
||||
html += `<img src="data:image/png;base64,${data.image}" alt="Grid capture" />`;
|
||||
}
|
||||
html += `<div class="grid-view" style="grid-template-columns:repeat(${data.cols},12px)">`;
|
||||
const set = new Set(data.occupied.map(c => c.row + ',' + c.col));
|
||||
for (let r = 0; r < data.rows; r++) {
|
||||
for (let c = 0; c < data.cols; c++) {
|
||||
html += `<div class="grid-cell${set.has(r+','+c) ? ' occupied' : ''}" title="(${r},${c})"></div>`;
|
||||
}
|
||||
}
|
||||
html += '</div></div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function debugAngeOption(option) {
|
||||
showDebugResult(`Clicking ANGE → ${option}...`);
|
||||
const res = await fetch('/api/debug/click-then-click', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ first: 'ANGE', second: option }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.found && data.step === 'first') {
|
||||
showDebugResult('ANGE not found on screen');
|
||||
} else if (!data.found) {
|
||||
showDebugResult(`"${option}" not found in ANGE menu (timed out)`);
|
||||
} else {
|
||||
showDebugResult(`Clicked "${option}" at (${data.position.x}, ${data.position.y})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function debugHideout() {
|
||||
showDebugResult('Sending /hideout...');
|
||||
const res = await fetch('/api/debug/hideout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showDebugResult(data.ok ? 'Sent /hideout command' : `Error: ${data.error}`);
|
||||
}
|
||||
|
||||
async function debugScreenshot() {
|
||||
const res = await fetch('/api/debug/screenshot', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
|
@ -620,8 +734,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function debugFindAndClick() {
|
||||
const text = document.getElementById('debugTextInput').value.trim();
|
||||
async function debugFindAndClick(directText) {
|
||||
const text = directText || document.getElementById('debugTextInput').value.trim();
|
||||
if (!text) return;
|
||||
showDebugResult(`Finding and clicking "${text}"...`);
|
||||
const res = await fetch('/api/debug/find-and-click', {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class GameController {
|
|||
// Clear any existing text
|
||||
await this.inputSender.selectAll();
|
||||
await sleep(50);
|
||||
await this.inputSender.pressKey(VK.DELETE);
|
||||
await this.inputSender.pressKey(VK.BACK);
|
||||
await sleep(50);
|
||||
|
||||
// Type the message
|
||||
|
|
@ -66,7 +66,7 @@ export class GameController {
|
|||
// Clear any existing text
|
||||
await this.inputSender.selectAll();
|
||||
await sleep(50);
|
||||
await this.inputSender.pressKey(VK.DELETE);
|
||||
await this.inputSender.pressKey(VK.BACK);
|
||||
await sleep(50);
|
||||
|
||||
// Paste
|
||||
|
|
@ -80,7 +80,7 @@ export class GameController {
|
|||
|
||||
async goToHideout(): Promise<void> {
|
||||
logger.info('Sending /hideout command');
|
||||
await this.sendChat('/hideout');
|
||||
await this.sendChatViaPaste('/hideout');
|
||||
}
|
||||
|
||||
async ctrlRightClickAt(x: number, y: number): Promise<void> {
|
||||
|
|
|
|||
157
src/game/GridReader.ts
Normal file
157
src/game/GridReader.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { logger } from '../util/logger.js';
|
||||
import type { OcrDaemon } from './OcrDaemon.js';
|
||||
import type { Region } from '../types.js';
|
||||
|
||||
// ── Grid type definitions ───────────────────────────────────────────────────
|
||||
|
||||
export interface GridLayout {
|
||||
region: Region;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface CellCoord {
|
||||
row: number;
|
||||
col: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
layout: GridLayout;
|
||||
occupied: CellCoord[];
|
||||
}
|
||||
|
||||
// ── Calibrated grid layouts (2560×1440) ─────────────────────────────────────
|
||||
|
||||
export const GRID_LAYOUTS: Record<string, GridLayout> = {
|
||||
/** Player inventory — always 12×5, right side (below equipment slots) */
|
||||
inventory: {
|
||||
region: { x: 1696, y: 788, width: 840, height: 350 },
|
||||
cols: 12,
|
||||
rows: 5,
|
||||
},
|
||||
/** Personal stash 12×12 — left side, tab not in folder */
|
||||
stash12: {
|
||||
region: { x: 23, y: 169, width: 840, height: 840 },
|
||||
cols: 12,
|
||||
rows: 12,
|
||||
},
|
||||
/** Personal stash 12×12 — left side, tab in folder */
|
||||
stash12_folder: {
|
||||
region: { x: 23, y: 216, width: 840, height: 840 },
|
||||
cols: 12,
|
||||
rows: 12,
|
||||
},
|
||||
/** Personal stash 24×24 (quad tab) — left side, tab not in folder */
|
||||
stash24: {
|
||||
region: { x: 23, y: 169, width: 840, height: 840 },
|
||||
cols: 24,
|
||||
rows: 24,
|
||||
},
|
||||
/** Personal stash 24×24 (quad tab) — left side, tab in folder */
|
||||
stash24_folder: {
|
||||
region: { x: 23, y: 216, width: 840, height: 840 },
|
||||
cols: 24,
|
||||
rows: 24,
|
||||
},
|
||||
/** Seller's public stash — always 12×12 */
|
||||
seller: {
|
||||
region: { x: 416, y: 299, width: 840, height: 840 },
|
||||
cols: 12,
|
||||
rows: 12,
|
||||
},
|
||||
/** NPC shop — 12×12 */
|
||||
shop: {
|
||||
region: { x: 23, y: 216, width: 840, height: 840 },
|
||||
cols: 12,
|
||||
rows: 12,
|
||||
},
|
||||
/** NPC vendor inventory — 12×12 */
|
||||
vendor: {
|
||||
region: { x: 416, y: 369, width: 840, height: 840 },
|
||||
cols: 12,
|
||||
rows: 12,
|
||||
},
|
||||
};
|
||||
|
||||
// Backward-compat exports
|
||||
export const INVENTORY = GRID_LAYOUTS.inventory;
|
||||
export const STASH_12x12 = GRID_LAYOUTS.stash12;
|
||||
export const STASH_24x24 = GRID_LAYOUTS.stash24;
|
||||
export const SELLER_12x12 = GRID_LAYOUTS.seller;
|
||||
|
||||
// ── GridReader ──────────────────────────────────────────────────────────────
|
||||
|
||||
export class GridReader {
|
||||
constructor(private daemon: OcrDaemon) {}
|
||||
|
||||
/**
|
||||
* Scan a named grid layout for occupied cells.
|
||||
*/
|
||||
async scan(layoutName: string, threshold?: number): Promise<ScanResult> {
|
||||
const layout = GRID_LAYOUTS[layoutName];
|
||||
if (!layout) throw new Error(`Unknown grid layout: ${layoutName}`);
|
||||
|
||||
const t = performance.now();
|
||||
const occupied = await this.getOccupiedCells(layout, threshold);
|
||||
|
||||
const ms = (performance.now() - t).toFixed(0);
|
||||
logger.info(
|
||||
{ layoutName, cols: layout.cols, rows: layout.rows, occupied: occupied.length, ms },
|
||||
'Grid scan complete',
|
||||
);
|
||||
|
||||
return { layout, occupied };
|
||||
}
|
||||
|
||||
/** Get the screen-space center of a grid cell */
|
||||
getCellCenter(layout: GridLayout, row: number, col: number): { x: number; y: number } {
|
||||
const cellW = layout.region.width / layout.cols;
|
||||
const cellH = layout.region.height / layout.rows;
|
||||
return {
|
||||
x: Math.round(layout.region.x + col * cellW + cellW / 2),
|
||||
y: Math.round(layout.region.y + row * cellH + cellH / 2),
|
||||
};
|
||||
}
|
||||
|
||||
/** Scan the grid and return which cells are occupied */
|
||||
async getOccupiedCells(layout: GridLayout, threshold?: number): Promise<CellCoord[]> {
|
||||
const t = performance.now();
|
||||
const cells = await this.daemon.gridScan(
|
||||
layout.region,
|
||||
layout.cols,
|
||||
layout.rows,
|
||||
threshold,
|
||||
);
|
||||
|
||||
const occupied: CellCoord[] = [];
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
for (let col = 0; col < cells[row].length; col++) {
|
||||
if (cells[row][col]) {
|
||||
const center = this.getCellCenter(layout, row, col);
|
||||
occupied.push({ row, col, x: center.x, y: center.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ms = (performance.now() - t).toFixed(0);
|
||||
logger.info(
|
||||
{ layout: `${layout.cols}x${layout.rows}`, occupied: occupied.length, ms },
|
||||
'Grid scan complete',
|
||||
);
|
||||
return occupied;
|
||||
}
|
||||
|
||||
/** Get all cell centers in the grid */
|
||||
getAllCells(layout: GridLayout): CellCoord[] {
|
||||
const cells: CellCoord[] = [];
|
||||
for (let row = 0; row < layout.rows; row++) {
|
||||
for (let col = 0; col < layout.cols; col++) {
|
||||
const center = this.getCellCenter(layout, row, col);
|
||||
cells.push({ row, col, x: center.x, y: center.y });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ export class InputSender {
|
|||
y: start.y + dy * 0.75 + perpY * (Math.random() - 0.5) * spread,
|
||||
};
|
||||
|
||||
const steps = clamp(Math.round(distance / 15), 15, 40);
|
||||
const steps = clamp(Math.round(distance / 30), 8, 20);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const rawT = i / steps;
|
||||
|
|
@ -214,30 +214,30 @@ export class InputSender {
|
|||
const jitterY = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0;
|
||||
|
||||
this.moveMouseRaw(Math.round(pt.x) + jitterX, Math.round(pt.y) + jitterY);
|
||||
await sleep(2 + Math.random() * 3); // 2-5ms between steps
|
||||
await sleep(1 + Math.random() * 2); // 1-3ms between steps
|
||||
}
|
||||
|
||||
// Final exact landing
|
||||
this.moveMouseRaw(x, y);
|
||||
await randomDelay(10, 25);
|
||||
await randomDelay(5, 15);
|
||||
}
|
||||
|
||||
async leftClick(x: number, y: number): Promise<void> {
|
||||
await this.moveMouse(x, y);
|
||||
await randomDelay(50, 100);
|
||||
await randomDelay(20, 50);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN);
|
||||
await randomDelay(30, 80);
|
||||
await randomDelay(15, 40);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP);
|
||||
await randomDelay(30, 60);
|
||||
await randomDelay(15, 30);
|
||||
}
|
||||
|
||||
async rightClick(x: number, y: number): Promise<void> {
|
||||
await this.moveMouse(x, y);
|
||||
await randomDelay(50, 100);
|
||||
await randomDelay(20, 50);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN);
|
||||
await randomDelay(30, 80);
|
||||
await randomDelay(15, 40);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP);
|
||||
await randomDelay(30, 60);
|
||||
await randomDelay(15, 30);
|
||||
}
|
||||
|
||||
async ctrlRightClick(x: number, y: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,24 @@ export interface OcrResponse {
|
|||
lines: OcrLine[];
|
||||
}
|
||||
|
||||
export interface DetectGridResult {
|
||||
detected: boolean;
|
||||
region?: Region;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
}
|
||||
|
||||
interface DaemonRequest {
|
||||
cmd: string;
|
||||
region?: Region;
|
||||
path?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
threshold?: number;
|
||||
minCellSize?: number;
|
||||
maxCellSize?: number;
|
||||
}
|
||||
|
||||
interface DaemonResponse {
|
||||
|
|
@ -36,6 +50,13 @@ interface DaemonResponse {
|
|||
text?: string;
|
||||
lines?: OcrLine[];
|
||||
image?: string;
|
||||
cells?: boolean[][];
|
||||
detected?: boolean;
|
||||
region?: Region;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +106,28 @@ export class OcrDaemon {
|
|||
return Buffer.from(resp.image!, 'base64');
|
||||
}
|
||||
|
||||
async gridScan(region: Region, cols: number, rows: number, threshold?: number): Promise<boolean[][]> {
|
||||
const req: DaemonRequest = { cmd: 'grid', region, cols, rows };
|
||||
if (threshold) req.threshold = threshold;
|
||||
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
|
||||
return resp.cells ?? [];
|
||||
}
|
||||
|
||||
async detectGrid(region: Region, minCellSize?: number, maxCellSize?: number): Promise<DetectGridResult> {
|
||||
const req: DaemonRequest = { cmd: 'detect-grid', region };
|
||||
if (minCellSize) req.minCellSize = minCellSize;
|
||||
if (maxCellSize) req.maxCellSize = maxCellSize;
|
||||
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
|
||||
return {
|
||||
detected: resp.detected ?? false,
|
||||
region: resp.region,
|
||||
cols: resp.cols,
|
||||
rows: resp.rows,
|
||||
cellWidth: resp.cellWidth,
|
||||
cellHeight: resp.cellHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async saveScreenshot(path: string, region?: Region): Promise<void> {
|
||||
const req: DaemonRequest = { cmd: 'screenshot', path };
|
||||
if (region) req.region = region;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdir } from 'fs/promises';
|
|||
import { join } from 'path';
|
||||
import { logger } from '../util/logger.js';
|
||||
import { OcrDaemon, type OcrResponse } from './OcrDaemon.js';
|
||||
import { GridReader, type GridLayout, type CellCoord } from './GridReader.js';
|
||||
import type { Region } from '../types.js';
|
||||
|
||||
function elapsed(start: number): string {
|
||||
|
|
@ -10,6 +11,7 @@ function elapsed(start: number): string {
|
|||
|
||||
export class ScreenReader {
|
||||
private daemon = new OcrDaemon();
|
||||
readonly grid = new GridReader(this.daemon);
|
||||
|
||||
// ── Screenshot capture ──────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ program
|
|||
const gameController = new GameController(config);
|
||||
dashboard.setDebugDeps({ screenReader, gameController });
|
||||
|
||||
// Go to hideout on startup
|
||||
dashboard.broadcastLog('info', 'Sending /hideout command...');
|
||||
await gameController.focusGame();
|
||||
await gameController.goToHideout();
|
||||
bot.state = 'IN_HIDEOUT';
|
||||
dashboard.broadcastStatus();
|
||||
dashboard.broadcastLog('info', 'In hideout, ready to trade');
|
||||
|
||||
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
|
||||
await logWatcher.start();
|
||||
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ while ((line = stdin.ReadLine()) != null)
|
|||
case "capture":
|
||||
HandleCapture(request);
|
||||
break;
|
||||
case "grid":
|
||||
HandleGrid(request);
|
||||
break;
|
||||
case "detect-grid":
|
||||
HandleDetectGrid(request);
|
||||
break;
|
||||
default:
|
||||
WriteResponse(new ErrorResponse($"Unknown command: {request.Cmd}"));
|
||||
break;
|
||||
|
|
@ -74,7 +80,7 @@ return 0;
|
|||
|
||||
void HandleOcr(Request req, OcrEngine engine)
|
||||
{
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
using var bitmap = CaptureOrLoad(req.File, req.Region);
|
||||
var softwareBitmap = BitmapToSoftwareBitmap(bitmap);
|
||||
var result = engine.RecognizeAsync(softwareBitmap).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
|
|
@ -107,7 +113,7 @@ void HandleScreenshot(Request req)
|
|||
return;
|
||||
}
|
||||
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
using var bitmap = CaptureOrLoad(req.File, req.Region);
|
||||
var format = GetImageFormat(req.Path);
|
||||
bitmap.Save(req.Path, format);
|
||||
WriteResponse(new OkResponse());
|
||||
|
|
@ -115,15 +121,573 @@ void HandleScreenshot(Request req)
|
|||
|
||||
void HandleCapture(Request req)
|
||||
{
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
using var bitmap = CaptureOrLoad(req.File, req.Region);
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, ImageFormat.Png);
|
||||
var base64 = Convert.ToBase64String(ms.ToArray());
|
||||
WriteResponse(new CaptureResponse { Image = base64 });
|
||||
}
|
||||
|
||||
// Pre-loaded empty cell templates (loaded lazily on first grid scan)
|
||||
byte[]? emptyTemplate70Gray = null;
|
||||
int emptyTemplate70W = 0, emptyTemplate70H = 0;
|
||||
byte[]? emptyTemplate35Gray = null;
|
||||
int emptyTemplate35W = 0, emptyTemplate35H = 0;
|
||||
|
||||
void LoadTemplatesIfNeeded()
|
||||
{
|
||||
if (emptyTemplate70Gray != null) return;
|
||||
|
||||
// Look for templates relative to exe directory
|
||||
var exeDir = AppContext.BaseDirectory;
|
||||
// Templates are in assets/ at project root — walk up from bin/Release/net8.0-.../
|
||||
var projectRoot = System.IO.Path.GetFullPath(System.IO.Path.Combine(exeDir, "..", "..", "..", "..", ".."));
|
||||
var t70Path = System.IO.Path.Combine(projectRoot, "assets", "empty70.png");
|
||||
var t35Path = System.IO.Path.Combine(projectRoot, "assets", "empty35.png");
|
||||
|
||||
if (System.IO.File.Exists(t70Path))
|
||||
{
|
||||
using var bmp = new Bitmap(t70Path);
|
||||
emptyTemplate70W = bmp.Width;
|
||||
emptyTemplate70H = bmp.Height;
|
||||
emptyTemplate70Gray = BitmapToGray(bmp);
|
||||
}
|
||||
if (System.IO.File.Exists(t35Path))
|
||||
{
|
||||
using var bmp = new Bitmap(t35Path);
|
||||
emptyTemplate35W = bmp.Width;
|
||||
emptyTemplate35H = bmp.Height;
|
||||
emptyTemplate35Gray = BitmapToGray(bmp);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] BitmapToGray(Bitmap bmp)
|
||||
{
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] pixels = new byte[data.Stride * h];
|
||||
Marshal.Copy(data.Scan0, pixels, 0, pixels.Length);
|
||||
bmp.UnlockBits(data);
|
||||
int stride = data.Stride;
|
||||
|
||||
byte[] gray = new byte[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
gray[y * w + x] = (byte)((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3);
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
|
||||
void HandleGrid(Request req)
|
||||
{
|
||||
if (req.Region == null || req.Cols <= 0 || req.Rows <= 0)
|
||||
{
|
||||
WriteResponse(new ErrorResponse("grid command requires region, cols, rows"));
|
||||
return;
|
||||
}
|
||||
|
||||
LoadTemplatesIfNeeded();
|
||||
|
||||
using var bitmap = CaptureOrLoad(req.File, req.Region);
|
||||
int cols = req.Cols;
|
||||
int rows = req.Rows;
|
||||
float cellW = (float)bitmap.Width / cols;
|
||||
float cellH = (float)bitmap.Height / rows;
|
||||
|
||||
// Pick the right empty template based on cell size
|
||||
int nominalCell = (int)Math.Round(cellW);
|
||||
byte[]? templateGray;
|
||||
int templateW, templateH;
|
||||
if (nominalCell <= 40 && emptyTemplate35Gray != null)
|
||||
{
|
||||
templateGray = emptyTemplate35Gray;
|
||||
templateW = emptyTemplate35W;
|
||||
templateH = emptyTemplate35H;
|
||||
}
|
||||
else if (emptyTemplate70Gray != null)
|
||||
{
|
||||
templateGray = emptyTemplate70Gray;
|
||||
templateW = emptyTemplate70W;
|
||||
templateH = emptyTemplate70H;
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteResponse(new ErrorResponse("Empty cell templates not found in assets/"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert captured bitmap to grayscale
|
||||
byte[] captureGray = BitmapToGray(bitmap);
|
||||
int captureW = bitmap.Width;
|
||||
|
||||
// Border to skip (outer pixels may differ between cells)
|
||||
int border = Math.Max(2, nominalCell / 10);
|
||||
|
||||
// Pre-compute template average for the inner region
|
||||
long templateSum = 0;
|
||||
int innerCount = 0;
|
||||
for (int ty = border; ty < templateH - border; ty++)
|
||||
for (int tx = border; tx < templateW - border; tx++)
|
||||
{
|
||||
templateSum += templateGray[ty * templateW + tx];
|
||||
innerCount++;
|
||||
}
|
||||
|
||||
// Threshold for mean absolute difference — default 6
|
||||
double diffThreshold = req.Threshold > 0 ? req.Threshold : 2;
|
||||
bool debug = req.Debug;
|
||||
|
||||
if (debug) Console.Error.WriteLine($"Grid: {cols}x{rows}, cellW={cellW:F1}, cellH={cellH:F1}, border={border}, threshold={diffThreshold}");
|
||||
|
||||
var cells = new List<List<bool>>();
|
||||
for (int row = 0; row < rows; row++)
|
||||
{
|
||||
var rowList = new List<bool>();
|
||||
var debugDiffs = new List<string>();
|
||||
for (int col = 0; col < cols; col++)
|
||||
{
|
||||
int cx0 = (int)(col * cellW);
|
||||
int cy0 = (int)(row * cellH);
|
||||
int cw = (int)Math.Min(cellW, captureW - cx0);
|
||||
int ch = (int)Math.Min(cellH, bitmap.Height - cy0);
|
||||
|
||||
// Compare inner pixels of cell vs template
|
||||
long diffSum = 0;
|
||||
int compared = 0;
|
||||
int innerW = Math.Min(cw, templateW) - border;
|
||||
int innerH = Math.Min(ch, templateH) - border;
|
||||
for (int py = border; py < innerH; py++)
|
||||
{
|
||||
for (int px = border; px < innerW; px++)
|
||||
{
|
||||
int cellVal = captureGray[(cy0 + py) * captureW + (cx0 + px)];
|
||||
int tmplVal = templateGray[py * templateW + px];
|
||||
diffSum += Math.Abs(cellVal - tmplVal);
|
||||
compared++;
|
||||
}
|
||||
}
|
||||
double meanDiff = compared > 0 ? (double)diffSum / compared : 0;
|
||||
bool occupied = meanDiff > diffThreshold;
|
||||
rowList.Add(occupied);
|
||||
if (debug) debugDiffs.Add($"{meanDiff,5:F1}{(occupied ? "*" : " ")}");
|
||||
}
|
||||
cells.Add(rowList);
|
||||
if (debug) Console.Error.WriteLine($" Row {row,2}: {string.Join(" ", debugDiffs)}");
|
||||
}
|
||||
|
||||
WriteResponse(new GridResponse { Cells = cells });
|
||||
}
|
||||
|
||||
void HandleDetectGrid(Request req)
|
||||
{
|
||||
if (req.Region == null)
|
||||
{
|
||||
WriteResponse(new ErrorResponse("detect-grid requires region"));
|
||||
return;
|
||||
}
|
||||
|
||||
int minCell = req.MinCellSize > 0 ? req.MinCellSize : 20;
|
||||
int maxCell = req.MaxCellSize > 0 ? req.MaxCellSize : 70;
|
||||
bool debug = req.Debug;
|
||||
|
||||
Bitmap bitmap = CaptureOrLoad(req.File, req.Region);
|
||||
int w = bitmap.Width;
|
||||
int h = bitmap.Height;
|
||||
|
||||
var bmpData = bitmap.LockBits(
|
||||
new Rectangle(0, 0, w, h),
|
||||
ImageLockMode.ReadOnly,
|
||||
PixelFormat.Format32bppArgb
|
||||
);
|
||||
byte[] pixels = new byte[bmpData.Stride * h];
|
||||
Marshal.Copy(bmpData.Scan0, pixels, 0, pixels.Length);
|
||||
bitmap.UnlockBits(bmpData);
|
||||
int stride = bmpData.Stride;
|
||||
|
||||
byte[] gray = new byte[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
gray[y * w + x] = (byte)((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3);
|
||||
}
|
||||
|
||||
bitmap.Dispose();
|
||||
|
||||
// ── Pass 1: Scan horizontal bands using "very dark pixel density" ──
|
||||
// Grid lines are nearly all very dark (density ~0.9), cell interiors are
|
||||
// partially dark (0.3-0.5), game world is mostly bright (density ~0.05).
|
||||
// This creates clear periodic peaks at grid line positions.
|
||||
int bandH = 200;
|
||||
int bandStep = 40;
|
||||
const int veryDarkPixelThresh = 12; // pixels below this brightness = "very dark"
|
||||
const double gridSegThresh = 0.25; // density above this = potential grid column
|
||||
|
||||
var candidates = new List<(int bandY, int cellW, double hAc, int hLeft, int hRight)>();
|
||||
|
||||
for (int by = 0; by + bandH <= h; by += bandStep)
|
||||
{
|
||||
// "Very dark pixel density" per column: fraction of pixels below threshold
|
||||
double[] darkDensity = new double[w];
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int count = 0;
|
||||
for (int y = by; y < by + bandH; y++)
|
||||
{
|
||||
if (gray[y * w + x] < veryDarkPixelThresh) count++;
|
||||
}
|
||||
darkDensity[x] = (double)count / bandH;
|
||||
}
|
||||
|
||||
// Find segments where density > gridSegThresh (grid panel regions)
|
||||
var gridSegs = FindDarkDensitySegments(darkDensity, gridSegThresh, 200);
|
||||
|
||||
foreach (var (segLeft, segRight) in gridSegs)
|
||||
{
|
||||
// Extract segment and run AC
|
||||
int segLen = segRight - segLeft;
|
||||
double[] segment = new double[segLen];
|
||||
Array.Copy(darkDensity, segLeft, segment, 0, segLen);
|
||||
|
||||
var (period, acScore) = FindPeriodWithScore(segment, minCell, maxCell);
|
||||
|
||||
if (period <= 0) continue;
|
||||
|
||||
// FindGridExtent within the segment
|
||||
var (extLeft, extRight) = FindGridExtent(segment, period);
|
||||
if (extLeft < 0) continue;
|
||||
|
||||
// Map back to full image coordinates
|
||||
int absLeft = segLeft + extLeft;
|
||||
int absRight = segLeft + extRight;
|
||||
int extent = absRight - absLeft;
|
||||
|
||||
// Require at least 8 cells wide AND 200px absolute minimum
|
||||
if (extent < period * 8 || extent < 200) continue;
|
||||
|
||||
if (debug) Console.Error.WriteLine(
|
||||
$" Band y={by}: seg=[{segLeft}-{segRight}] period={period}, AC={acScore:F3}, " +
|
||||
$"extent={absLeft}-{absRight}={extent}px ({extent / period} cells)");
|
||||
|
||||
candidates.Add((by, period, acScore, absLeft, absRight));
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) Console.Error.WriteLine($"Pass 1: {candidates.Count} candidates");
|
||||
|
||||
// Sort by score = AC * extent (prefer large strongly-periodic areas)
|
||||
candidates.Sort((a, b) =>
|
||||
{
|
||||
double sa = a.hAc * (a.hRight - a.hLeft);
|
||||
double sb = b.hAc * (b.hRight - b.hLeft);
|
||||
return sb.CompareTo(sa);
|
||||
});
|
||||
|
||||
// ── Pass 2: Verify vertical periodicity ──
|
||||
foreach (var cand in candidates.Take(10))
|
||||
{
|
||||
int colSpan = cand.hRight - cand.hLeft;
|
||||
if (colSpan < cand.cellW * 3) continue;
|
||||
|
||||
// Row "very dark pixel density" within the detected column range
|
||||
double[] rowDensity = new double[h];
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int count = 0;
|
||||
for (int x = cand.hLeft; x < cand.hRight; x++)
|
||||
{
|
||||
if (gray[y * w + x] < veryDarkPixelThresh) count++;
|
||||
}
|
||||
rowDensity[y] = (double)count / colSpan;
|
||||
}
|
||||
|
||||
// Find grid panel vertical segment
|
||||
var vGridSegs = FindDarkDensitySegments(rowDensity, gridSegThresh, 100);
|
||||
if (vGridSegs.Count == 0) continue;
|
||||
|
||||
// Use the largest segment
|
||||
var (vSegTop, vSegBottom) = vGridSegs.OrderByDescending(s => s.end - s.start).First();
|
||||
int vSegLen = vSegBottom - vSegTop;
|
||||
double[] vSegment = new double[vSegLen];
|
||||
Array.Copy(rowDensity, vSegTop, vSegment, 0, vSegLen);
|
||||
|
||||
var (cellH, vAc) = FindPeriodWithScore(vSegment, minCell, maxCell);
|
||||
if (cellH <= 0) continue;
|
||||
|
||||
var (extTop, extBottom) = FindGridExtent(vSegment, cellH);
|
||||
if (extTop < 0) continue;
|
||||
|
||||
int top = vSegTop + extTop;
|
||||
int bottom = vSegTop + extBottom;
|
||||
int vExtent = bottom - top;
|
||||
|
||||
// Require at least 3 rows tall AND 100px absolute minimum
|
||||
if (vExtent < cellH * 3 || vExtent < 100) continue;
|
||||
|
||||
if (debug) Console.Error.WriteLine(
|
||||
$" 2D candidate: cellW={cand.cellW}, cellH={cellH}, " +
|
||||
$"region=({cand.hLeft},{top})-({cand.hRight},{bottom}), " +
|
||||
$"vAC={vAc:F3}, extent={vExtent}px ({vExtent / cellH} rows)");
|
||||
|
||||
// ── Found a valid 2D grid ──
|
||||
int gridW = cand.hRight - cand.hLeft;
|
||||
int gridH = bottom - top;
|
||||
int cols = Math.Max(2, (int)Math.Round((double)gridW / cand.cellW));
|
||||
int rows = Math.Max(2, (int)Math.Round((double)gridH / cellH));
|
||||
|
||||
// Snap grid dimensions to exact multiples of cell size
|
||||
gridW = cols * cand.cellW;
|
||||
gridH = rows * cellH;
|
||||
|
||||
if (debug) Console.Error.WriteLine(
|
||||
$" => cols={cols}, rows={rows}, gridW={gridW}, gridH={gridH}");
|
||||
|
||||
WriteResponse(new DetectGridResponse
|
||||
{
|
||||
Detected = true,
|
||||
Region = new RegionRect
|
||||
{
|
||||
X = req.Region.X + cand.hLeft,
|
||||
Y = req.Region.Y + top,
|
||||
Width = gridW,
|
||||
Height = gridH,
|
||||
},
|
||||
Cols = cols,
|
||||
Rows = rows,
|
||||
CellWidth = Math.Round((double)gridW / cols, 1),
|
||||
CellHeight = Math.Round((double)gridH / rows, 1),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) Console.Error.WriteLine(" No valid 2D grid found");
|
||||
WriteResponse(new DetectGridResponse { Detected = false });
|
||||
}
|
||||
|
||||
/// Find the dominant period in a signal using autocorrelation.
|
||||
/// Returns (period, score) where score is the autocorrelation strength.
|
||||
(int period, double score) FindPeriodWithScore(double[] signal, int minPeriod, int maxPeriod)
|
||||
{
|
||||
int n = signal.Length;
|
||||
if (n < minPeriod * 3) return (-1, 0);
|
||||
|
||||
double mean = signal.Average();
|
||||
double variance = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
variance += (signal[i] - mean) * (signal[i] - mean);
|
||||
if (variance < 1.0) return (-1, 0);
|
||||
|
||||
int maxLag = Math.Min(maxPeriod, n / 3);
|
||||
double[] ac = new double[maxLag + 1];
|
||||
for (int lag = minPeriod; lag <= maxLag; lag++)
|
||||
{
|
||||
double sum = 0;
|
||||
for (int i = 0; i < n - lag; i++)
|
||||
sum += (signal[i] - mean) * (signal[i + lag] - mean);
|
||||
ac[lag] = sum / variance;
|
||||
}
|
||||
|
||||
// Find the first significant peak — this is the fundamental period.
|
||||
// Using "first" avoids picking harmonics (2x, 3x) or unrelated larger patterns.
|
||||
for (int lag = minPeriod + 1; lag < maxLag; lag++)
|
||||
{
|
||||
if (ac[lag] > 0.01 && ac[lag] >= ac[lag - 1] && ac[lag] >= ac[lag + 1])
|
||||
return (lag, ac[lag]);
|
||||
}
|
||||
|
||||
return (-1, 0);
|
||||
}
|
||||
|
||||
/// Find contiguous segments where values are ABOVE threshold.
|
||||
/// Used to find grid panel regions by density of very dark pixels.
|
||||
/// Allows brief gaps (up to 5px) to handle grid borders.
|
||||
List<(int start, int end)> FindDarkDensitySegments(double[] profile, double threshold, int minLength)
|
||||
{
|
||||
var segments = new List<(int start, int end)>();
|
||||
int n = profile.Length;
|
||||
int curStart = -1;
|
||||
int maxGap = 5;
|
||||
int gapCount = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (profile[i] >= threshold)
|
||||
{
|
||||
if (curStart < 0) curStart = i;
|
||||
gapCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (curStart >= 0)
|
||||
{
|
||||
gapCount++;
|
||||
if (gapCount > maxGap)
|
||||
{
|
||||
int end = i - gapCount;
|
||||
if (end - curStart >= minLength)
|
||||
segments.Add((curStart, end));
|
||||
curStart = -1;
|
||||
gapCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (curStart >= 0)
|
||||
{
|
||||
int end = gapCount > 0 ? n - gapCount : n;
|
||||
if (end - curStart >= minLength)
|
||||
segments.Add((curStart, end));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// Debug: find the top N AC peaks in a signal
|
||||
List<(int lag, double ac)> FindTopAcPeaks(double[] signal, int minPeriod, int maxPeriod, int topN)
|
||||
{
|
||||
int n = signal.Length;
|
||||
if (n < minPeriod * 3) return [];
|
||||
|
||||
double mean = signal.Average();
|
||||
double variance = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
variance += (signal[i] - mean) * (signal[i] - mean);
|
||||
if (variance < 1.0) return [];
|
||||
|
||||
int maxLag = Math.Min(maxPeriod, n / 3);
|
||||
var peaks = new List<(int lag, double ac)>();
|
||||
double[] ac = new double[maxLag + 1];
|
||||
for (int lag = minPeriod; lag <= maxLag; lag++)
|
||||
{
|
||||
double sum = 0;
|
||||
for (int i = 0; i < n - lag; i++)
|
||||
sum += (signal[i] - mean) * (signal[i + lag] - mean);
|
||||
ac[lag] = sum / variance;
|
||||
}
|
||||
for (int lag = minPeriod + 1; lag < maxLag; lag++)
|
||||
{
|
||||
if (ac[lag] >= ac[lag - 1] && ac[lag] >= ac[lag + 1] && ac[lag] > 0.005)
|
||||
peaks.Add((lag, ac[lag]));
|
||||
}
|
||||
peaks.Sort((a, b) => b.ac.CompareTo(a.ac));
|
||||
return peaks.Take(topN).ToList();
|
||||
}
|
||||
|
||||
/// Find the extent of the grid in a 1D profile using local autocorrelation
|
||||
/// at the specific detected period. Only regions where the signal actually
|
||||
/// repeats at the given period will score high — much more precise than variance.
|
||||
(int start, int end) FindGridExtent(double[] signal, int period)
|
||||
{
|
||||
int n = signal.Length;
|
||||
int halfWin = period * 2; // window radius: 2 periods each side
|
||||
if (n < halfWin * 2 + period) return (-1, -1);
|
||||
|
||||
// Compute local AC at the specific lag=period in a sliding window
|
||||
double[] localAc = new double[n];
|
||||
for (int center = halfWin; center < n - halfWin; center++)
|
||||
{
|
||||
int wStart = center - halfWin;
|
||||
int wEnd = center + halfWin;
|
||||
int count = wEnd - wStart;
|
||||
|
||||
// Local mean
|
||||
double sum = 0;
|
||||
for (int i = wStart; i < wEnd; i++)
|
||||
sum += signal[i];
|
||||
double mean = sum / count;
|
||||
|
||||
// Local variance
|
||||
double varSum = 0;
|
||||
for (int i = wStart; i < wEnd; i++)
|
||||
varSum += (signal[i] - mean) * (signal[i] - mean);
|
||||
|
||||
if (varSum < 1.0) continue;
|
||||
|
||||
// AC at the specific lag=period
|
||||
double acSum = 0;
|
||||
for (int i = wStart; i < wEnd - period; i++)
|
||||
acSum += (signal[i] - mean) * (signal[i + period] - mean);
|
||||
|
||||
localAc[center] = Math.Max(0, acSum / varSum);
|
||||
}
|
||||
|
||||
// Find the longest contiguous run above threshold
|
||||
double maxAc = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (localAc[i] > maxAc) maxAc = localAc[i];
|
||||
if (maxAc < 0.02) return (-1, -1);
|
||||
|
||||
double threshold = maxAc * 0.25;
|
||||
|
||||
int bestStart = -1, bestEnd = -1, bestLen = 0;
|
||||
int curStart = -1;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (localAc[i] > threshold)
|
||||
{
|
||||
if (curStart < 0) curStart = i;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (curStart >= 0)
|
||||
{
|
||||
int len = i - curStart;
|
||||
if (len > bestLen)
|
||||
{
|
||||
bestLen = len;
|
||||
bestStart = curStart;
|
||||
bestEnd = i;
|
||||
}
|
||||
curStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle run extending to end of signal
|
||||
if (curStart >= 0)
|
||||
{
|
||||
int len = n - curStart;
|
||||
if (len > bestLen)
|
||||
{
|
||||
bestStart = curStart;
|
||||
bestEnd = n;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestStart < 0) return (-1, -1);
|
||||
|
||||
// Small extension to include cell borders at edges
|
||||
bestStart = Math.Max(0, bestStart - period / 4);
|
||||
bestEnd = Math.Min(n - 1, bestEnd + period / 4);
|
||||
|
||||
return (bestStart, bestEnd);
|
||||
}
|
||||
|
||||
// ── Screen Capture ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Capture from screen, or load from file if specified.
|
||||
/// When file is set, loads the image and crops to region.
|
||||
Bitmap CaptureOrLoad(string? file, RegionRect? region)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(file))
|
||||
{
|
||||
var fullBmp = new Bitmap(file);
|
||||
if (region != null)
|
||||
{
|
||||
int cx = Math.Max(0, region.X);
|
||||
int cy = Math.Max(0, region.Y);
|
||||
int cw = Math.Min(region.Width, fullBmp.Width - cx);
|
||||
int ch = Math.Min(region.Height, fullBmp.Height - cy);
|
||||
var cropped = fullBmp.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
|
||||
fullBmp.Dispose();
|
||||
return cropped;
|
||||
}
|
||||
return fullBmp;
|
||||
}
|
||||
return CaptureScreen(region);
|
||||
}
|
||||
|
||||
Bitmap CaptureScreen(RegionRect? region)
|
||||
{
|
||||
int x, y, w, h;
|
||||
|
|
@ -203,6 +767,27 @@ class Request
|
|||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; set; }
|
||||
|
||||
[JsonPropertyName("cols")]
|
||||
public int Cols { get; set; }
|
||||
|
||||
[JsonPropertyName("rows")]
|
||||
public int Rows { get; set; }
|
||||
|
||||
[JsonPropertyName("threshold")]
|
||||
public int Threshold { get; set; }
|
||||
|
||||
[JsonPropertyName("minCellSize")]
|
||||
public int MinCellSize { get; set; }
|
||||
|
||||
[JsonPropertyName("maxCellSize")]
|
||||
public int MaxCellSize { get; set; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; set; }
|
||||
|
||||
[JsonPropertyName("debug")]
|
||||
public bool Debug { get; set; }
|
||||
}
|
||||
|
||||
class RegionRect
|
||||
|
|
@ -291,3 +876,36 @@ class CaptureResponse
|
|||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; } = "";
|
||||
}
|
||||
|
||||
class GridResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("cells")]
|
||||
public List<List<bool>> Cells { get; set; } = [];
|
||||
}
|
||||
|
||||
class DetectGridResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("detected")]
|
||||
public bool Detected { get; set; }
|
||||
|
||||
[JsonPropertyName("region")]
|
||||
public RegionRect? Region { get; set; }
|
||||
|
||||
[JsonPropertyName("cols")]
|
||||
public int Cols { get; set; }
|
||||
|
||||
[JsonPropertyName("rows")]
|
||||
public int Rows { get; set; }
|
||||
|
||||
[JsonPropertyName("cellWidth")]
|
||||
public double CellWidth { get; set; }
|
||||
|
||||
[JsonPropertyName("cellHeight")]
|
||||
public double CellHeight { get; set; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue