switched to new way
20
.gitignore
vendored
|
|
@ -1,15 +1,25 @@
|
|||
# C# build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
.venv/
|
||||
|
||||
# Secrets / config
|
||||
.env
|
||||
config.json
|
||||
|
||||
# Runtime data
|
||||
browser-data/
|
||||
*.log
|
||||
debug-screenshots/
|
||||
items/
|
||||
eng.traineddata
|
||||
|
||||
# IDE / tools
|
||||
.claude/
|
||||
nul
|
||||
|
||||
# OcrDaemon build output
|
||||
tools/OcrDaemon/bin/
|
||||
tools/OcrDaemon/obj/
|
||||
|
|
|
|||
83
Poe2Trade.sln
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Core", "src\Poe2Trade.Core\Poe2Trade.Core.csproj", "{6432F6A5-11A0-4960-AFFC-E810D4325C35}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Game", "src\Poe2Trade.Game\Poe2Trade.Game.csproj", "{97B8362D-777C-4ED1-B964-D6598B333E4C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Screen", "src\Poe2Trade.Screen\Poe2Trade.Screen.csproj", "{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Items", "src\Poe2Trade.Items\Poe2Trade.Items.csproj", "{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Trade", "src\Poe2Trade.Trade\Poe2Trade.Trade.csproj", "{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Log", "src\Poe2Trade.Log\Poe2Trade.Log.csproj", "{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Bot", "src\Poe2Trade.Bot\Poe2Trade.Bot.csproj", "{188C4F87-153F-4182-B816-9FB56F08CF3A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Inventory", "src\Poe2Trade.Inventory\Poe2Trade.Inventory.csproj", "{F186DDC8-6843-43E9-8BD3-9F914C5E784E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Ui", "src\Poe2Trade.Ui\Poe2Trade.Ui.csproj", "{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{6432F6A5-11A0-4960-AFFC-E810D4325C35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6432F6A5-11A0-4960-AFFC-E810D4325C35}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6432F6A5-11A0-4960-AFFC-E810D4325C35}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6432F6A5-11A0-4960-AFFC-E810D4325C35}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{97B8362D-777C-4ED1-B964-D6598B333E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{97B8362D-777C-4ED1-B964-D6598B333E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{97B8362D-777C-4ED1-B964-D6598B333E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{97B8362D-777C-4ED1-B964-D6598B333E4C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{188C4F87-153F-4182-B816-9FB56F08CF3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{188C4F87-153F-4182-B816-9FB56F08CF3A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{188C4F87-153F-4182-B816-9FB56F08CF3A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{188C4F87-153F-4182-B816-9FB56F08CF3A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{97B8362D-777C-4ED1-B964-D6598B333E4C} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{F92C5EA2-8999-41BC-9B28-D52AD5F3542C} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{8F73A696-EB54-4C6F-9603-5A6BAC5D334A} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{B68D787D-7A83-4D8F-9F10-0B72C2E99B49} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{188C4F87-153F-4182-B816-9FB56F08CF3A} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{F186DDC8-6843-43E9-8BD3-9F914C5E784E} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"cmd":"crop-test","engine":"diff"}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
{"ok":true,"ready":true}
|
||||
{"ok":true,"method":"edge","avgIoU":0.7689866918165986,"results":[{"id":"1","iou":0.9028985507246376,"expected":{"x":0,"y":84,"width":1185,"height":690},"actual":{"x":0,"y":117,"width":1185,"height":623},"deltaTop":33,"deltaLeft":0,"deltaRight":0,"deltaBottom":-34},{"id":"2","iou":0.6861386480207926,"expected":{"x":304,"y":0,"width":679,"height":470},"actual":{"x":428,"y":40,"width":564,"height":474},"deltaTop":40,"deltaLeft":124,"deltaRight":9,"deltaBottom":44},{"id":"3","iou":0.8734518726233722,"expected":{"x":473,"y":334,"width":641,"height":580},"actual":{"x":472,"y":373,"width":609,"height":548},"deltaTop":39,"deltaLeft":-1,"deltaRight":-33,"deltaBottom":7},{"id":"4","iou":0.4827177898385173,"expected":{"x":209,"y":264,"width":888,"height":651},"actual":{"x":0,"y":294,"width":767,"height":634},"deltaTop":30,"deltaLeft":-209,"deltaRight":-330,"deltaBottom":13},{"id":"5","iou":0.8933684252502293,"expected":{"x":763,"y":0,"width":1111,"height":560},"actual":{"x":758,"y":39,"width":1080,"height":523},"deltaTop":39,"deltaLeft":-5,"deltaRight":-36,"deltaBottom":2},{"id":"6","iou":0.9159954398801851,"expected":{"x":1541,"y":154,"width":807,"height":460},"actual":{"x":1486,"y":157,"width":870,"height":460},"deltaTop":3,"deltaLeft":-55,"deltaRight":8,"deltaBottom":3},{"id":"7","iou":0.6283361163784564,"expected":{"x":1921,"y":40,"width":637,"height":330},"actual":{"x":1946,"y":72,"width":447,"height":302},"deltaTop":32,"deltaLeft":25,"deltaRight":-165,"deltaBottom":4}]}
|
||||
2888
package-lock.json
generated
36
package.json
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"name": "poe2trade",
|
||||
"version": "1.0.0",
|
||||
"description": "POE2 trade bot - automated item purchasing via trade site monitoring",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "dotnet build tools/OcrDaemon -c Release && tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"build:daemon": "dotnet build tools/OcrDaemon -c Release",
|
||||
"start": "node dist/index.js",
|
||||
"stop:daemon": "taskkill /IM OcrDaemon.exe /F 2>nul || exit /b 0",
|
||||
"test:ocr": "taskkill /IM OcrDaemon.exe /F 2>nul & dotnet build tools/OcrDaemon -c Release && echo {\"cmd\":\"test\"} | tools\\OcrDaemon\\bin\\Release\\net8.0-windows10.0.19041.0\\OcrDaemon.exe",
|
||||
"tune:ocr": "taskkill /IM OcrDaemon.exe /F 2>nul & dotnet build tools/OcrDaemon -c Release && echo {\"cmd\":\"tune\"} | tools\\OcrDaemon\\bin\\Release\\net8.0-windows10.0.19041.0\\OcrDaemon.exe",
|
||||
"generate:words": "node tools/OcrDaemon/tessdata/generate-words.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"clipboard-sys": "^1.2.0",
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.2.1",
|
||||
"koffi": "^2.9.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.50.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
296
src/Poe2Trade.Bot/BotOrchestrator.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.GameLog;
|
||||
using Poe2Trade.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class BotStatus
|
||||
{
|
||||
public bool Paused { get; set; }
|
||||
public string State { get; set; } = "Idle";
|
||||
public List<TradeLink> Links { get; set; } = [];
|
||||
public int TradesCompleted { get; set; }
|
||||
public int TradesFailed { get; set; }
|
||||
public long Uptime { get; set; }
|
||||
}
|
||||
|
||||
public class BotOrchestrator : IAsyncDisposable
|
||||
{
|
||||
private bool _paused;
|
||||
private string _state = "Idle";
|
||||
private int _tradesCompleted;
|
||||
private int _tradesFailed;
|
||||
private readonly long _startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
private bool _started;
|
||||
|
||||
public LinkManager Links { get; }
|
||||
public ConfigStore Store { get; }
|
||||
public AppConfig Config { get; }
|
||||
|
||||
public GameController Game { get; private set; } = null!;
|
||||
public ScreenReader Screen { get; private set; } = null!;
|
||||
public ClientLogWatcher LogWatcher { get; private set; } = null!;
|
||||
public TradeMonitor TradeMonitor { get; private set; } = null!;
|
||||
public InventoryManager Inventory { get; private set; } = null!;
|
||||
public TradeExecutor TradeExecutor { get; private set; } = null!;
|
||||
public TradeQueue TradeQueue { get; private set; } = null!;
|
||||
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
|
||||
|
||||
// Events
|
||||
public event Action? StatusUpdated;
|
||||
public event Action<string, string>? LogMessage; // level, message
|
||||
|
||||
public BotOrchestrator(ConfigStore store, AppConfig config)
|
||||
{
|
||||
Store = store;
|
||||
Config = config;
|
||||
_paused = store.Settings.Paused;
|
||||
Links = new LinkManager(store);
|
||||
}
|
||||
|
||||
public bool IsReady => _started;
|
||||
public bool IsPaused => _paused;
|
||||
|
||||
public string State
|
||||
{
|
||||
get => _state;
|
||||
set
|
||||
{
|
||||
if (_state == value) return;
|
||||
_state = value;
|
||||
StatusUpdated?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
_paused = true;
|
||||
Store.SetPaused(true);
|
||||
Log.Information("Bot paused");
|
||||
StatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
_paused = false;
|
||||
Store.SetPaused(false);
|
||||
Log.Information("Bot resumed");
|
||||
StatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public TradeLink AddLink(string url, string? name = null, LinkMode? mode = null, PostAction? postAction = null)
|
||||
{
|
||||
var link = Links.AddLink(url, name ?? "", mode, postAction);
|
||||
StatusUpdated?.Invoke();
|
||||
return link;
|
||||
}
|
||||
|
||||
public void RemoveLink(string id)
|
||||
{
|
||||
Links.RemoveLink(id);
|
||||
StatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public void ToggleLink(string id, bool active)
|
||||
{
|
||||
var link = Links.ToggleLink(id, active);
|
||||
if (link == null) return;
|
||||
StatusUpdated?.Invoke();
|
||||
|
||||
if (active)
|
||||
_ = ActivateLink(link);
|
||||
else
|
||||
_ = DeactivateLink(id);
|
||||
}
|
||||
|
||||
public BotStatus GetStatus() => new()
|
||||
{
|
||||
Paused = _paused,
|
||||
State = _state,
|
||||
Links = Links.GetLinks(),
|
||||
TradesCompleted = _tradesCompleted,
|
||||
TradesFailed = _tradesFailed,
|
||||
Uptime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTime,
|
||||
};
|
||||
|
||||
public void UpdateExecutorState()
|
||||
{
|
||||
var execState = TradeExecutor.State;
|
||||
if (execState != TradeState.Idle)
|
||||
{
|
||||
State = execState.ToString();
|
||||
return;
|
||||
}
|
||||
foreach (var scrapExec in _scrapExecutors.Values)
|
||||
{
|
||||
if (scrapExec.State != ScrapState.Idle)
|
||||
{
|
||||
State = scrapExec.State.ToString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
State = "Idle";
|
||||
}
|
||||
|
||||
public async Task Start(IReadOnlyList<string> cliUrls)
|
||||
{
|
||||
Screen = new ScreenReader();
|
||||
Game = new GameController(Config);
|
||||
LogWatcher = new ClientLogWatcher(Config.Poe2LogPath);
|
||||
LogWatcher.Start();
|
||||
Emit("info", "Watching Client.txt for game events");
|
||||
|
||||
TradeMonitor = new TradeMonitor(Config);
|
||||
await TradeMonitor.Start();
|
||||
Emit("info", "Browser launched");
|
||||
|
||||
Inventory = new InventoryManager(Game, Screen, LogWatcher, Config);
|
||||
|
||||
// Warmup OCR daemon
|
||||
var ocrWarmup = Screen.Warmup().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) Log.Warning(t.Exception!, "OCR warmup failed");
|
||||
});
|
||||
|
||||
// Check if already in hideout
|
||||
var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase);
|
||||
if (inHideout)
|
||||
{
|
||||
Log.Information("Already in hideout: {Area}", LogWatcher.CurrentArea);
|
||||
Inventory.SetLocation(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Emit("info", "Sending /hideout command...");
|
||||
await Game.FocusGame();
|
||||
var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout());
|
||||
Inventory.SetLocation(true);
|
||||
if (!arrivedHome)
|
||||
Log.Warning("Timed out waiting for hideout transition on startup");
|
||||
}
|
||||
|
||||
State = "InHideout";
|
||||
Emit("info", "In hideout, ready to trade");
|
||||
|
||||
await ocrWarmup;
|
||||
|
||||
Emit("info", "Checking inventory for leftover items...");
|
||||
await Inventory.ClearToStash();
|
||||
Emit("info", "Inventory cleared");
|
||||
|
||||
// Create executors
|
||||
TradeExecutor = new TradeExecutor(Game, Screen, TradeMonitor, Inventory, Config);
|
||||
TradeExecutor.StateChanged += _ => UpdateExecutorState();
|
||||
TradeQueue = new TradeQueue(TradeExecutor, Config);
|
||||
TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
|
||||
TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
|
||||
|
||||
// Load links
|
||||
var allUrls = new HashSet<string>(cliUrls);
|
||||
foreach (var l in Store.Settings.Links)
|
||||
allUrls.Add(l.Url);
|
||||
|
||||
foreach (var url in allUrls)
|
||||
{
|
||||
var link = Links.AddLink(url);
|
||||
if (link.Active)
|
||||
await ActivateLink(link);
|
||||
else
|
||||
Emit("info", $"Loaded (inactive): {link.Name}");
|
||||
}
|
||||
|
||||
// Wire trade monitor events
|
||||
TradeMonitor.NewListings += OnNewListings;
|
||||
|
||||
_started = true;
|
||||
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
|
||||
Log.Information("Bot started");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Log.Information("Shutting down bot...");
|
||||
foreach (var exec in _scrapExecutors.Values)
|
||||
await exec.Stop();
|
||||
Screen.Dispose();
|
||||
await TradeMonitor.DisposeAsync();
|
||||
LogWatcher.Dispose();
|
||||
}
|
||||
|
||||
private void OnNewListings(string searchId, List<string> itemIds, IPage page)
|
||||
{
|
||||
if (_paused)
|
||||
{
|
||||
Emit("warn", $"New listings ({itemIds.Count}) skipped - bot paused");
|
||||
return;
|
||||
}
|
||||
if (!Links.IsActive(searchId)) return;
|
||||
|
||||
Log.Information("New listings: {SearchId} ({Count} items)", searchId, itemIds.Count);
|
||||
Emit("info", $"New listings: {itemIds.Count} items from {searchId}");
|
||||
|
||||
TradeQueue.Enqueue(new TradeInfo(
|
||||
SearchId: searchId,
|
||||
ItemIds: itemIds,
|
||||
WhisperText: "",
|
||||
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
TradeUrl: "",
|
||||
Page: page
|
||||
));
|
||||
}
|
||||
|
||||
private async Task ActivateLink(TradeLink link)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (link.Mode == LinkMode.Scrap)
|
||||
{
|
||||
var scrapExec = new ScrapExecutor(Game, Screen, TradeMonitor, Inventory, Config);
|
||||
scrapExec.StateChanged += _ => UpdateExecutorState();
|
||||
scrapExec.ItemBought += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
|
||||
scrapExec.ItemFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
|
||||
_scrapExecutors[link.Id] = scrapExec;
|
||||
Emit("info", $"Scrap loop started: {link.Name}");
|
||||
StatusUpdated?.Invoke();
|
||||
|
||||
_ = scrapExec.RunScrapLoop(link.Url, link.PostAction).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
{
|
||||
Log.Error(t.Exception!, "Scrap loop error: {LinkId}", link.Id);
|
||||
Emit("error", $"Scrap loop failed: {link.Name}");
|
||||
_scrapExecutors.Remove(link.Id);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await TradeMonitor.AddSearch(link.Url);
|
||||
Emit("info", $"Monitoring: {link.Name}");
|
||||
StatusUpdated?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to activate link: {Url}", link.Url);
|
||||
Emit("error", $"Failed to activate: {link.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeactivateLink(string id)
|
||||
{
|
||||
if (_scrapExecutors.TryGetValue(id, out var scrapExec))
|
||||
{
|
||||
await scrapExec.Stop();
|
||||
_scrapExecutors.Remove(id);
|
||||
}
|
||||
await TradeMonitor.PauseSearch(id);
|
||||
}
|
||||
|
||||
private void Emit(string level, string message) => LogMessage?.Invoke(level, message);
|
||||
}
|
||||
15
src/Poe2Trade.Bot/Poe2Trade.Bot.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Trade\Poe2Trade.Trade.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Inventory\Poe2Trade.Inventory.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
222
src/Poe2Trade.Bot/ScrapExecutor.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class ScrapExecutor
|
||||
{
|
||||
private ScrapState _state = ScrapState.Idle;
|
||||
private bool _stopped;
|
||||
private IPage? _activePage;
|
||||
private PostAction _postAction = PostAction.Salvage;
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly TradeMonitor _tradeMonitor;
|
||||
private readonly InventoryManager _inventory;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public event Action<ScrapState>? StateChanged;
|
||||
public event Action? ItemBought;
|
||||
public event Action? ItemFailed;
|
||||
|
||||
public ScrapExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
|
||||
InventoryManager inventory, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_tradeMonitor = tradeMonitor;
|
||||
_inventory = inventory;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public ScrapState State => _state;
|
||||
|
||||
private void SetState(ScrapState s)
|
||||
{
|
||||
_state = s;
|
||||
StateChanged?.Invoke(s);
|
||||
}
|
||||
|
||||
public async Task Stop()
|
||||
{
|
||||
_stopped = true;
|
||||
if (_activePage != null)
|
||||
{
|
||||
try { await _activePage.CloseAsync(); } catch { }
|
||||
_activePage = null;
|
||||
}
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Scrap executor stopped");
|
||||
}
|
||||
|
||||
public async Task RunScrapLoop(string tradeUrl, PostAction postAction = PostAction.Salvage)
|
||||
{
|
||||
_stopped = false;
|
||||
_postAction = postAction;
|
||||
Log.Information("Starting scrap loop: {Url} postAction={Action}", tradeUrl, postAction);
|
||||
|
||||
await _inventory.ScanInventory(_postAction);
|
||||
|
||||
var (page, items) = await _tradeMonitor.OpenScrapPage(tradeUrl);
|
||||
_activePage = page;
|
||||
Log.Information("Trade page opened: {Count} items", items.Count);
|
||||
|
||||
while (!_stopped)
|
||||
{
|
||||
var salvageFailed = false;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (_stopped) break;
|
||||
|
||||
if (!_inventory.Tracker.CanFit(item.W, item.H))
|
||||
{
|
||||
if (salvageFailed) continue;
|
||||
Log.Information("No room for {W}x{H}, processing...", item.W, item.H);
|
||||
await ProcessItems();
|
||||
if (_state == ScrapState.Failed)
|
||||
{
|
||||
salvageFailed = true;
|
||||
SetState(ScrapState.Idle);
|
||||
continue;
|
||||
}
|
||||
await _inventory.ScanInventory(_postAction);
|
||||
}
|
||||
|
||||
if (!_inventory.Tracker.CanFit(item.W, item.H))
|
||||
{
|
||||
Log.Warning("Item {W}x{H} still cannot fit after processing, skipping", item.W, item.H);
|
||||
continue;
|
||||
}
|
||||
|
||||
var success = await BuyItem(page, item);
|
||||
if (!success) Log.Warning("Failed to buy item {Id}", item.Id);
|
||||
|
||||
await Helpers.RandomDelay(500, 1000);
|
||||
}
|
||||
|
||||
if (_stopped) break;
|
||||
|
||||
Log.Information("Page exhausted, refreshing...");
|
||||
items = await RefreshPage(page);
|
||||
Log.Information("Page refreshed: {Count} items", items.Count);
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
Log.Information("No items after refresh, waiting...");
|
||||
await Helpers.Sleep(5000);
|
||||
if (_stopped) break;
|
||||
items = await RefreshPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
_activePage = null;
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Scrap loop ended");
|
||||
}
|
||||
|
||||
private async Task<bool> BuyItem(IPage page, TradeItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyAtSeller = !_inventory.IsAtOwnHideout
|
||||
&& !string.IsNullOrEmpty(item.Account)
|
||||
&& item.Account == _inventory.SellerAccount;
|
||||
|
||||
if (alreadyAtSeller)
|
||||
{
|
||||
Log.Information("Already at seller hideout, skipping travel");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetState(ScrapState.Traveling);
|
||||
var arrived = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs,
|
||||
async () =>
|
||||
{
|
||||
if (!await _tradeMonitor.ClickTravelToHideout(page, item.Id))
|
||||
throw new Exception("Failed to click Travel to Hideout");
|
||||
});
|
||||
if (!arrived)
|
||||
{
|
||||
Log.Error("Timed out waiting for hideout arrival: {ItemId}", item.Id);
|
||||
SetState(ScrapState.Failed);
|
||||
return false;
|
||||
}
|
||||
_inventory.SetLocation(false, item.Account);
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(1500);
|
||||
}
|
||||
|
||||
SetState(ScrapState.Buying);
|
||||
var sellerLayout = GridLayouts.Seller;
|
||||
var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
|
||||
Log.Information("CTRL+clicking seller stash at ({X},{Y})", cellCenter.X, cellCenter.Y);
|
||||
|
||||
await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
|
||||
_inventory.Tracker.TryPlace(item.W, item.H, _postAction);
|
||||
Log.Information("Item bought: {Id} (free={Free})", item.Id, _inventory.Tracker.FreeCells);
|
||||
SetState(ScrapState.Idle);
|
||||
ItemBought?.Invoke();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error buying item {Id}", item.Id);
|
||||
SetState(ScrapState.Failed);
|
||||
ItemFailed?.Invoke();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessItems()
|
||||
{
|
||||
try
|
||||
{
|
||||
SetState(ScrapState.Salvaging);
|
||||
await _inventory.ProcessInventory();
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Process cycle complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Process cycle failed");
|
||||
SetState(ScrapState.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<TradeItem>> RefreshPage(IPage page)
|
||||
{
|
||||
var items = new List<TradeItem>();
|
||||
|
||||
void OnResponse(object? _, IResponse response)
|
||||
{
|
||||
if (!response.Url.Contains("/api/trade2/fetch/")) return;
|
||||
try
|
||||
{
|
||||
var body = response.TextAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("result", out var results) &&
|
||||
results.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in results.EnumerateArray())
|
||||
items.Add(TradeMonitor.ParseTradeItem(r));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
page.Response += OnResponse;
|
||||
await page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
page.Response -= OnResponse;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
147
src/Poe2Trade.Bot/TradeExecutor.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class TradeExecutor
|
||||
{
|
||||
private TradeState _state = TradeState.Idle;
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly TradeMonitor _tradeMonitor;
|
||||
private readonly InventoryManager _inventory;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public event Action<TradeState>? StateChanged;
|
||||
|
||||
public TradeExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
|
||||
InventoryManager inventory, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_tradeMonitor = tradeMonitor;
|
||||
_inventory = inventory;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public TradeState State => _state;
|
||||
|
||||
private void SetState(TradeState s)
|
||||
{
|
||||
_state = s;
|
||||
StateChanged?.Invoke(s);
|
||||
}
|
||||
|
||||
public async Task<bool> ExecuteTrade(TradeInfo trade)
|
||||
{
|
||||
var page = trade.Page as IPage;
|
||||
if (page == null)
|
||||
{
|
||||
Log.Error("Trade has no page reference");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Travel to seller hideout
|
||||
SetState(TradeState.Traveling);
|
||||
Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
|
||||
|
||||
var arrived = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs,
|
||||
async () =>
|
||||
{
|
||||
if (!await _tradeMonitor.ClickTravelToHideout(page, trade.ItemIds[0]))
|
||||
throw new Exception("Failed to click Travel to Hideout");
|
||||
});
|
||||
if (!arrived)
|
||||
{
|
||||
Log.Error("Timed out waiting for hideout arrival");
|
||||
SetState(TradeState.Failed);
|
||||
return false;
|
||||
}
|
||||
|
||||
SetState(TradeState.InSellersHideout);
|
||||
_inventory.SetLocation(false);
|
||||
Log.Information("Arrived at seller hideout");
|
||||
|
||||
// Step 2: Focus game and find stash
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(1500);
|
||||
|
||||
var angePos = await _inventory.FindAndClickNameplate("Ange");
|
||||
if (angePos == null)
|
||||
Log.Warning("Could not find Ange nameplate, trying Stash directly");
|
||||
else
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var stashPos = await _inventory.FindAndClickNameplate("Stash");
|
||||
if (stashPos == null)
|
||||
{
|
||||
Log.Error("Could not find Stash in seller hideout");
|
||||
SetState(TradeState.Failed);
|
||||
return false;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
// Step 3: Scan stash and buy
|
||||
SetState(TradeState.ScanningStash);
|
||||
await ScanAndBuyItems();
|
||||
|
||||
// Step 4: Wait for more
|
||||
SetState(TradeState.WaitingForMore);
|
||||
Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
|
||||
await Helpers.Sleep(_config.WaitForMoreItemsMs);
|
||||
await ScanAndBuyItems();
|
||||
|
||||
// Step 5: Go home
|
||||
SetState(TradeState.GoingHome);
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
|
||||
var home = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs, () => _game.GoToHideout());
|
||||
if (!home) Log.Warning("Timed out going home");
|
||||
|
||||
_inventory.SetLocation(true);
|
||||
|
||||
// Step 6: Store items
|
||||
SetState(TradeState.InHideout);
|
||||
await Helpers.Sleep(1000);
|
||||
await _inventory.ProcessInventory();
|
||||
|
||||
SetState(TradeState.Idle);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Trade execution failed");
|
||||
SetState(TradeState.Failed);
|
||||
|
||||
try
|
||||
{
|
||||
await _game.FocusGame();
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
await _game.GoToHideout();
|
||||
}
|
||||
catch { /* best-effort recovery */ }
|
||||
|
||||
SetState(TradeState.Idle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAndBuyItems()
|
||||
{
|
||||
var stashRegion = new Region(20, 140, 630, 750);
|
||||
var stashText = await _screen.ReadRegionText(stashRegion);
|
||||
Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText);
|
||||
SetState(TradeState.Buying);
|
||||
}
|
||||
}
|
||||
71
src/Poe2Trade.Bot/TradeQueue.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class TradeQueue
|
||||
{
|
||||
private readonly Queue<TradeInfo> _queue = new();
|
||||
private readonly TradeExecutor _executor;
|
||||
private readonly AppConfig _config;
|
||||
private bool _processing;
|
||||
|
||||
public TradeQueue(TradeExecutor executor, AppConfig config)
|
||||
{
|
||||
_executor = executor;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public int Length => _queue.Count;
|
||||
public bool IsProcessing => _processing;
|
||||
|
||||
public event Action? TradeCompleted;
|
||||
public event Action? TradeFailed;
|
||||
|
||||
public void Enqueue(TradeInfo trade)
|
||||
{
|
||||
var existingIds = _queue.SelectMany(t => t.ItemIds).ToHashSet();
|
||||
var newIds = trade.ItemIds.Where(id => !existingIds.Contains(id)).ToList();
|
||||
if (newIds.Count == 0)
|
||||
{
|
||||
Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds));
|
||||
return;
|
||||
}
|
||||
|
||||
var deduped = trade with { ItemIds = newIds };
|
||||
_queue.Enqueue(deduped);
|
||||
Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count);
|
||||
_ = ProcessNext();
|
||||
}
|
||||
|
||||
private async Task ProcessNext()
|
||||
{
|
||||
if (_processing || _queue.Count == 0) return;
|
||||
_processing = true;
|
||||
|
||||
var trade = _queue.Dequeue();
|
||||
try
|
||||
{
|
||||
Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count);
|
||||
var success = await _executor.ExecuteTrade(trade);
|
||||
if (success)
|
||||
{
|
||||
Log.Information("Trade completed");
|
||||
TradeCompleted?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Trade failed");
|
||||
TradeFailed?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Trade execution error");
|
||||
}
|
||||
|
||||
_processing = false;
|
||||
await Helpers.RandomDelay(_config.BetweenTradesDelayMs, _config.BetweenTradesDelayMs + 3000);
|
||||
_ = ProcessNext();
|
||||
}
|
||||
}
|
||||
29
src/Poe2Trade.Core/AppConfig.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class AppConfig
|
||||
{
|
||||
public List<string> TradeUrls { get; set; } = [];
|
||||
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
|
||||
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
|
||||
public string BrowserUserDataDir { get; set; } = "./browser-data";
|
||||
public int TravelTimeoutMs { get; set; } = 15000;
|
||||
public int StashScanTimeoutMs { get; set; } = 10000;
|
||||
public int WaitForMoreItemsMs { get; set; } = 20000;
|
||||
public int BetweenTradesDelayMs { get; set; } = 5000;
|
||||
|
||||
public static AppConfig Load(string? configPath = null)
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
var path = configPath ?? "appsettings.json";
|
||||
if (File.Exists(path))
|
||||
{
|
||||
builder.AddJsonFile(path, optional: true);
|
||||
}
|
||||
var configuration = builder.Build();
|
||||
var config = new AppConfig();
|
||||
configuration.Bind(config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
147
src/Poe2Trade.Core/ConfigStore.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class SavedLink
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public bool Active { get; set; } = true;
|
||||
public LinkMode Mode { get; set; } = LinkMode.Live;
|
||||
public PostAction PostAction { get; set; } = PostAction.Stash;
|
||||
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public class SavedSettings
|
||||
{
|
||||
public bool Paused { get; set; }
|
||||
public List<SavedLink> Links { get; set; } = [];
|
||||
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
|
||||
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
|
||||
public string BrowserUserDataDir { get; set; } = "./browser-data";
|
||||
public int TravelTimeoutMs { get; set; } = 15000;
|
||||
public int StashScanTimeoutMs { get; set; } = 10000;
|
||||
public int WaitForMoreItemsMs { get; set; } = 20000;
|
||||
public int BetweenTradesDelayMs { get; set; } = 5000;
|
||||
public double? WindowX { get; set; }
|
||||
public double? WindowY { get; set; }
|
||||
public double? WindowWidth { get; set; }
|
||||
public double? WindowHeight { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private SavedSettings _data;
|
||||
|
||||
public ConfigStore(string? configPath = null)
|
||||
{
|
||||
_filePath = configPath ?? Path.GetFullPath("config.json");
|
||||
_data = Load();
|
||||
}
|
||||
|
||||
public SavedSettings Settings => _data;
|
||||
public IReadOnlyList<SavedLink> Links => _data.Links;
|
||||
|
||||
public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null)
|
||||
{
|
||||
url = StripLive(url);
|
||||
if (_data.Links.Any(l => l.Url == url)) return;
|
||||
_data.Links.Add(new SavedLink
|
||||
{
|
||||
Url = url,
|
||||
Name = name,
|
||||
Active = true,
|
||||
Mode = mode,
|
||||
PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
|
||||
AddedAt = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
Save();
|
||||
}
|
||||
|
||||
public void RemoveLink(string url)
|
||||
{
|
||||
_data.Links.RemoveAll(l => l.Url == url);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void RemoveLinkById(string id)
|
||||
{
|
||||
_data.Links.RemoveAll(l => l.Url.Split('/').Last() == id);
|
||||
Save();
|
||||
}
|
||||
|
||||
public SavedLink? UpdateLinkById(string id, Action<SavedLink> update)
|
||||
{
|
||||
var link = _data.Links.FirstOrDefault(l => l.Url.Split('/').Last() == id);
|
||||
if (link == null) return null;
|
||||
update(link);
|
||||
Save();
|
||||
return link;
|
||||
}
|
||||
|
||||
public void SetPaused(bool paused)
|
||||
{
|
||||
_data.Paused = paused;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void UpdateSettings(Action<SavedSettings> update)
|
||||
{
|
||||
update(_data);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_data, JsonOptions);
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to save config.json to {Path}", _filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private SavedSettings Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
Log.Information("No config.json found at {Path}, using defaults", _filePath);
|
||||
return new SavedSettings();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllText(_filePath);
|
||||
var parsed = JsonSerializer.Deserialize<SavedSettings>(raw, JsonOptions);
|
||||
if (parsed == null) return new SavedSettings();
|
||||
|
||||
// Migrate links: strip /live from URLs
|
||||
foreach (var link in parsed.Links)
|
||||
{
|
||||
link.Url = StripLive(link.Url);
|
||||
}
|
||||
|
||||
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
|
||||
return parsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to read config.json at {Path}, using defaults", _filePath);
|
||||
return new SavedSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripLive(string url) => System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
}
|
||||
14
src/Poe2Trade.Core/Helpers.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace Poe2Trade.Core;
|
||||
|
||||
public static class Helpers
|
||||
{
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
public static Task Sleep(int ms) => Task.Delay(ms);
|
||||
|
||||
public static Task RandomDelay(int minMs, int maxMs)
|
||||
{
|
||||
var delay = Rng.Next(minMs, maxMs + 1);
|
||||
return Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
129
src/Poe2Trade.Core/LinkManager.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class TradeLink
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Url { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public bool Active { get; set; } = true;
|
||||
public LinkMode Mode { get; set; } = LinkMode.Live;
|
||||
public PostAction PostAction { get; set; } = PostAction.Stash;
|
||||
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public class LinkManager
|
||||
{
|
||||
private readonly Dictionary<string, TradeLink> _links = new();
|
||||
private readonly ConfigStore _store;
|
||||
|
||||
public LinkManager(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null)
|
||||
{
|
||||
url = StripLive(url);
|
||||
var id = ExtractId(url);
|
||||
var label = ExtractLabel(url);
|
||||
var savedLink = _store.Links.FirstOrDefault(l => l.Url == url);
|
||||
var resolvedMode = mode ?? savedLink?.Mode ?? LinkMode.Live;
|
||||
var link = new TradeLink
|
||||
{
|
||||
Id = id,
|
||||
Url = url,
|
||||
Name = name != "" ? name : savedLink?.Name ?? "",
|
||||
Label = label,
|
||||
Active = savedLink?.Active ?? true,
|
||||
Mode = resolvedMode,
|
||||
PostAction = postAction ?? savedLink?.PostAction ?? (resolvedMode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
|
||||
AddedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
_links[id] = link;
|
||||
_store.AddLink(url, link.Name, link.Mode, link.PostAction);
|
||||
Log.Information("Trade link added: {Id} {Url} mode={Mode}", id, url, link.Mode);
|
||||
return link;
|
||||
}
|
||||
|
||||
public void RemoveLink(string id)
|
||||
{
|
||||
if (_links.TryGetValue(id, out var link))
|
||||
{
|
||||
_links.Remove(id);
|
||||
_store.RemoveLink(link.Url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_store.RemoveLinkById(id);
|
||||
}
|
||||
Log.Information("Trade link removed: {Id}", id);
|
||||
}
|
||||
|
||||
public TradeLink? ToggleLink(string id, bool active)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.Active = active;
|
||||
_store.UpdateLinkById(id, l => l.Active = active);
|
||||
Log.Information("Trade link {Action}: {Id}", active ? "activated" : "deactivated", id);
|
||||
return link;
|
||||
}
|
||||
|
||||
public void UpdateName(string id, string name)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return;
|
||||
link.Name = name;
|
||||
_store.UpdateLinkById(id, l => l.Name = name);
|
||||
}
|
||||
|
||||
public TradeLink? UpdateMode(string id, LinkMode mode)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.Mode = mode;
|
||||
_store.UpdateLinkById(id, l => l.Mode = mode);
|
||||
return link;
|
||||
}
|
||||
|
||||
public TradeLink? UpdatePostAction(string id, PostAction postAction)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.PostAction = postAction;
|
||||
_store.UpdateLinkById(id, l => l.PostAction = postAction);
|
||||
return link;
|
||||
}
|
||||
|
||||
public bool IsActive(string id) => _links.TryGetValue(id, out var link) && link.Active;
|
||||
|
||||
public List<TradeLink> GetLinks() => _links.Values.ToList();
|
||||
|
||||
public TradeLink? GetLink(string id) => _links.GetValueOrDefault(id);
|
||||
|
||||
private static string StripLive(string url) =>
|
||||
System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
|
||||
private static string ExtractId(string url)
|
||||
{
|
||||
var parts = url.Split('/');
|
||||
return parts.Length > 0 ? parts[^1] : url;
|
||||
}
|
||||
|
||||
private static string ExtractLabel(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var poe2Idx = Array.IndexOf(parts, "poe2");
|
||||
if (poe2Idx >= 0 && parts.Length > poe2Idx + 2)
|
||||
{
|
||||
var league = Uri.UnescapeDataString(parts[poe2Idx + 1]);
|
||||
var searchId = parts[poe2Idx + 2];
|
||||
return $"{league} / {searchId}";
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
return url.Length > 60 ? url[..60] : url;
|
||||
}
|
||||
}
|
||||
19
src/Poe2Trade.Core/Logging.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public static class Logging
|
||||
{
|
||||
public static void Setup()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console(
|
||||
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.File("logs/poe2trade-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
16
src/Poe2Trade.Core/Poe2Trade.Core.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
70
src/Poe2Trade.Core/Types.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
namespace Poe2Trade.Core;
|
||||
|
||||
public record Region(int X, int Y, int Width, int Height);
|
||||
|
||||
public record TradeInfo(
|
||||
string SearchId,
|
||||
List<string> ItemIds,
|
||||
string WhisperText,
|
||||
long Timestamp,
|
||||
string TradeUrl,
|
||||
object? Page // Playwright Page reference
|
||||
);
|
||||
|
||||
public record TradeItem(
|
||||
string Id,
|
||||
int W,
|
||||
int H,
|
||||
int StashX,
|
||||
int StashY,
|
||||
string Account
|
||||
);
|
||||
|
||||
public record LogEvent(
|
||||
DateTime Timestamp,
|
||||
LogEventType Type,
|
||||
Dictionary<string, string> Data
|
||||
);
|
||||
|
||||
public enum LogEventType
|
||||
{
|
||||
AreaEntered,
|
||||
WhisperReceived,
|
||||
TradeAccepted,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public enum TradeState
|
||||
{
|
||||
Idle,
|
||||
Traveling,
|
||||
InSellersHideout,
|
||||
ScanningStash,
|
||||
Buying,
|
||||
WaitingForMore,
|
||||
GoingHome,
|
||||
InHideout,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum ScrapState
|
||||
{
|
||||
Idle,
|
||||
Traveling,
|
||||
Buying,
|
||||
Salvaging,
|
||||
Storing,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum LinkMode
|
||||
{
|
||||
Live,
|
||||
Scrap
|
||||
}
|
||||
|
||||
public enum PostAction
|
||||
{
|
||||
Stash,
|
||||
Salvage
|
||||
}
|
||||
111
src/Poe2Trade.Game/ClipboardHelper.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 clipboard access without WinForms dependency.
|
||||
/// </summary>
|
||||
public static class ClipboardHelper
|
||||
{
|
||||
public static string Read()
|
||||
{
|
||||
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
|
||||
return "";
|
||||
|
||||
try
|
||||
{
|
||||
var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT);
|
||||
if (handle == IntPtr.Zero) return "";
|
||||
|
||||
var ptr = ClipboardNative.GlobalLock(handle);
|
||||
if (ptr == IntPtr.Zero) return "";
|
||||
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStringUni(ptr) ?? "";
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.GlobalUnlock(handle);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.CloseClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(string text)
|
||||
{
|
||||
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
ClipboardNative.EmptyClipboard();
|
||||
var bytes = Encoding.Unicode.GetBytes(text + "\0");
|
||||
var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length);
|
||||
if (hGlobal == IntPtr.Zero) return;
|
||||
|
||||
var ptr = ClipboardNative.GlobalLock(hGlobal);
|
||||
if (ptr == IntPtr.Zero)
|
||||
{
|
||||
ClipboardNative.GlobalFree(hGlobal);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.GlobalUnlock(hGlobal);
|
||||
}
|
||||
|
||||
ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.CloseClipboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class ClipboardNative
|
||||
{
|
||||
public const uint CF_UNICODETEXT = 13;
|
||||
public const uint GMEM_MOVEABLE = 0x0002;
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool CloseClipboard();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool EmptyClipboard();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr GetClipboardData(uint uFormat);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
77
src/Poe2Trade.Game/GameController.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class GameController
|
||||
{
|
||||
private readonly WindowManager _windowManager;
|
||||
private readonly InputSender _input;
|
||||
|
||||
public GameController(AppConfig config)
|
||||
{
|
||||
_windowManager = new WindowManager(config.Poe2WindowTitle);
|
||||
_input = new InputSender();
|
||||
}
|
||||
|
||||
public async Task<bool> FocusGame()
|
||||
{
|
||||
var result = _windowManager.FocusWindow();
|
||||
if (result)
|
||||
await Helpers.Sleep(300);
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool IsGameFocused() => _windowManager.IsGameFocused();
|
||||
public RECT? GetWindowRect() => _windowManager.GetWindowRect();
|
||||
|
||||
public async Task SendChat(string message)
|
||||
{
|
||||
Log.Information("Sending chat message: {Message}", message);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.SelectAll();
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.BACK);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.TypeText(message);
|
||||
await Helpers.RandomDelay(50, 100);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.Sleep(100);
|
||||
}
|
||||
|
||||
public async Task SendChatViaPaste(string message)
|
||||
{
|
||||
Log.Information("Sending chat message via paste: {Message}", message);
|
||||
ClipboardHelper.Write(message);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.SelectAll();
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.BACK);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.Paste();
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.Sleep(100);
|
||||
}
|
||||
|
||||
public Task GoToHideout()
|
||||
{
|
||||
Log.Information("Sending /hideout command");
|
||||
return SendChatViaPaste("/hideout");
|
||||
}
|
||||
|
||||
public Task CtrlRightClickAt(int x, int y) => _input.CtrlRightClick(x, y);
|
||||
public Task MoveMouseTo(int x, int y) => _input.MoveMouse(x, y);
|
||||
public void MoveMouseInstant(int x, int y) => _input.MoveMouseInstant(x, y);
|
||||
public Task MoveMouseFast(int x, int y) => _input.MoveMouseFast(x, y);
|
||||
public Task LeftClickAt(int x, int y) => _input.LeftClick(x, y);
|
||||
public Task RightClickAt(int x, int y) => _input.RightClick(x, y);
|
||||
public Task PressEscape() => _input.PressKey(InputSender.VK.ESCAPE);
|
||||
public Task OpenInventory() => _input.PressKey(InputSender.VK.I);
|
||||
public Task CtrlLeftClickAt(int x, int y) => _input.CtrlLeftClick(x, y);
|
||||
public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL);
|
||||
public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL);
|
||||
}
|
||||
398
src/Poe2Trade.Game/InputSender.cs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using Poe2Trade.Core;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class InputSender
|
||||
{
|
||||
private readonly int _screenWidth;
|
||||
private readonly int _screenHeight;
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
public InputSender()
|
||||
{
|
||||
_screenWidth = InputNative.GetSystemMetrics(InputNative.SM_CXSCREEN);
|
||||
_screenHeight = InputNative.GetSystemMetrics(InputNative.SM_CYSCREEN);
|
||||
}
|
||||
|
||||
// Virtual key codes
|
||||
public static class VK
|
||||
{
|
||||
public const int RETURN = 0x0D;
|
||||
public const int CONTROL = 0x11;
|
||||
public const int MENU = 0x12;
|
||||
public const int SHIFT = 0x10;
|
||||
public const int ESCAPE = 0x1B;
|
||||
public const int TAB = 0x09;
|
||||
public const int SPACE = 0x20;
|
||||
public const int DELETE = 0x2E;
|
||||
public const int BACK = 0x08;
|
||||
public const int V = 0x56;
|
||||
public const int A = 0x41;
|
||||
public const int C = 0x43;
|
||||
public const int I = 0x49;
|
||||
}
|
||||
|
||||
public async Task PressKey(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyDown(scanCode);
|
||||
await Helpers.RandomDelay(30, 50);
|
||||
SendScanKeyUp(scanCode);
|
||||
await Helpers.RandomDelay(20, 40);
|
||||
}
|
||||
|
||||
public async Task KeyDown(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyDown(scanCode);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task KeyUp(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyUp(scanCode);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task TypeText(string text)
|
||||
{
|
||||
foreach (var ch in text)
|
||||
{
|
||||
SendUnicodeChar(ch);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Paste()
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await PressKey(VK.V);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.Sleep(50);
|
||||
}
|
||||
|
||||
public async Task SelectAll()
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await PressKey(VK.A);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.Sleep(50);
|
||||
}
|
||||
|
||||
public (int X, int Y) GetCursorPos()
|
||||
{
|
||||
InputNative.GetCursorPos(out var pt);
|
||||
return (pt.X, pt.Y);
|
||||
}
|
||||
|
||||
private void MoveMouseRaw(int x, int y)
|
||||
{
|
||||
var normalizedX = (int)Math.Round((double)x * 65535 / _screenWidth);
|
||||
var normalizedY = (int)Math.Round((double)y * 65535 / _screenHeight);
|
||||
SendMouseInput(normalizedX, normalizedY, 0,
|
||||
InputNative.MOUSEEVENTF_MOVE | InputNative.MOUSEEVENTF_ABSOLUTE);
|
||||
}
|
||||
|
||||
public async Task MoveMouse(int x, int y)
|
||||
{
|
||||
var (sx, sy) = GetCursorPos();
|
||||
var dx = x - sx;
|
||||
var dy = y - sy;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 10)
|
||||
{
|
||||
MoveMouseRaw(x, y);
|
||||
await Helpers.RandomDelay(10, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
var perpX = -dy / distance;
|
||||
var perpY = dx / distance;
|
||||
var spread = distance * 0.3;
|
||||
|
||||
var cp1X = sx + dx * 0.25 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp1Y = sy + dy * 0.25 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2X = sx + dx * 0.75 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2Y = sy + dy * 0.75 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
|
||||
var steps = Math.Clamp((int)Math.Round(distance / 30), 8, 20);
|
||||
|
||||
for (var i = 1; i <= steps; i++)
|
||||
{
|
||||
var rawT = (double)i / steps;
|
||||
var t = EaseInOutQuad(rawT);
|
||||
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
|
||||
|
||||
var jitterX = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
|
||||
var jitterY = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
|
||||
|
||||
MoveMouseRaw((int)Math.Round(px) + jitterX, (int)Math.Round(py) + jitterY);
|
||||
await Task.Delay(1 + Rng.Next(2));
|
||||
}
|
||||
|
||||
MoveMouseRaw(x, y);
|
||||
await Helpers.RandomDelay(5, 15);
|
||||
}
|
||||
|
||||
public void MoveMouseInstant(int x, int y) => MoveMouseRaw(x, y);
|
||||
|
||||
public async Task MoveMouseFast(int x, int y)
|
||||
{
|
||||
var (sx, sy) = GetCursorPos();
|
||||
var dx = x - sx;
|
||||
var dy = y - sy;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 10)
|
||||
{
|
||||
MoveMouseRaw(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
var perpX = -dy / distance;
|
||||
var perpY = dx / distance;
|
||||
var spread = distance * 0.15;
|
||||
|
||||
var cp1X = sx + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp1Y = sy + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2X = sx + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2Y = sy + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var t = EaseInOutQuad((double)i / 5);
|
||||
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
|
||||
MoveMouseRaw((int)Math.Round(px), (int)Math.Round(py));
|
||||
await Task.Delay(2);
|
||||
}
|
||||
MoveMouseRaw(x, y);
|
||||
}
|
||||
|
||||
public async Task LeftClick(int x, int y)
|
||||
{
|
||||
await MoveMouse(x, y);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTDOWN);
|
||||
await Helpers.RandomDelay(15, 40);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTUP);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task RightClick(int x, int y)
|
||||
{
|
||||
await MoveMouse(x, y);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTDOWN);
|
||||
await Helpers.RandomDelay(15, 40);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTUP);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task CtrlRightClick(int x, int y)
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
await RightClick(x, y);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
}
|
||||
|
||||
public async Task CtrlLeftClick(int x, int y)
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
await LeftClick(x, y);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
}
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
private void SendMouseInput(int dx, int dy, int mouseData, uint flags)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_MOUSE,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
mi = new InputNative.MOUSEINPUT
|
||||
{
|
||||
dx = dx, dy = dy,
|
||||
mouseData = mouseData,
|
||||
dwFlags = flags,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendScanKeyDown(uint scanCode)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0,
|
||||
wScan = (ushort)scanCode,
|
||||
dwFlags = InputNative.KEYEVENTF_SCANCODE,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendScanKeyUp(uint scanCode)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0,
|
||||
wScan = (ushort)scanCode,
|
||||
dwFlags = InputNative.KEYEVENTF_SCANCODE | InputNative.KEYEVENTF_KEYUP,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendUnicodeChar(char ch)
|
||||
{
|
||||
var code = (ushort)ch;
|
||||
var down = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0, wScan = code,
|
||||
dwFlags = InputNative.KEYEVENTF_UNICODE,
|
||||
time = 0, dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
var up = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0, wScan = code,
|
||||
dwFlags = InputNative.KEYEVENTF_UNICODE | InputNative.KEYEVENTF_KEYUP,
|
||||
time = 0, dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [down], Marshal.SizeOf<InputNative.INPUT>());
|
||||
InputNative.SendInput(1, [up], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private static double EaseInOutQuad(double t) =>
|
||||
t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
|
||||
|
||||
private static (double X, double Y) CubicBezier(double t,
|
||||
double p0x, double p0y, double p1x, double p1y,
|
||||
double p2x, double p2y, double p3x, double p3y)
|
||||
{
|
||||
var u = 1 - t;
|
||||
var u2 = u * u;
|
||||
var u3 = u2 * u;
|
||||
var t2 = t * t;
|
||||
var t3 = t2 * t;
|
||||
return (
|
||||
u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x,
|
||||
u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class InputNative
|
||||
{
|
||||
public const uint INPUT_MOUSE = 0;
|
||||
public const uint INPUT_KEYBOARD = 1;
|
||||
public const uint KEYEVENTF_SCANCODE = 0x0008;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
public const uint KEYEVENTF_UNICODE = 0x0004;
|
||||
public const uint MOUSEEVENTF_MOVE = 0x0001;
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
public const uint MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
public const uint MOUSEEVENTF_ABSOLUTE = 0x8000;
|
||||
public const int SM_CXSCREEN = 0;
|
||||
public const int SM_CYSCREEN = 1;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT
|
||||
{
|
||||
public int dx;
|
||||
public int dy;
|
||||
public int mouseData;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct InputUnion
|
||||
{
|
||||
[FieldOffset(0)] public MOUSEINPUT mi;
|
||||
[FieldOffset(0)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT
|
||||
{
|
||||
public uint type;
|
||||
public InputUnion u;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial int GetSystemMetrics(int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetCursorPos(out POINT lpPoint);
|
||||
}
|
||||
11
src/Poe2Trade.Game/Poe2Trade.Game.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
113
src/Poe2Trade.Game/WindowManager.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class WindowManager
|
||||
{
|
||||
private IntPtr _hwnd = IntPtr.Zero;
|
||||
private readonly string _windowTitle;
|
||||
|
||||
public WindowManager(string windowTitle)
|
||||
{
|
||||
_windowTitle = windowTitle;
|
||||
}
|
||||
|
||||
public IntPtr FindWindow()
|
||||
{
|
||||
_hwnd = NativeMethods.FindWindowW(null, _windowTitle);
|
||||
if (_hwnd == IntPtr.Zero)
|
||||
Log.Warning("Window not found: {Title}", _windowTitle);
|
||||
else
|
||||
Log.Information("Window found: {Title} hwnd={Hwnd}", _windowTitle, _hwnd);
|
||||
return _hwnd;
|
||||
}
|
||||
|
||||
public bool FocusWindow()
|
||||
{
|
||||
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
|
||||
FindWindow();
|
||||
if (_hwnd == IntPtr.Zero) return false;
|
||||
|
||||
// Restore if minimized
|
||||
NativeMethods.ShowWindow(_hwnd, NativeMethods.SW_RESTORE);
|
||||
|
||||
// Alt-key trick to bypass SetForegroundWindow restriction
|
||||
var altScan = NativeMethods.MapVirtualKeyW(NativeMethods.VK_MENU, 0);
|
||||
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, 0, UIntPtr.Zero);
|
||||
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero);
|
||||
|
||||
NativeMethods.BringWindowToTop(_hwnd);
|
||||
var result = NativeMethods.SetForegroundWindow(_hwnd);
|
||||
if (!result)
|
||||
Log.Warning("SetForegroundWindow failed");
|
||||
return result;
|
||||
}
|
||||
|
||||
public RECT? GetWindowRect()
|
||||
{
|
||||
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
|
||||
FindWindow();
|
||||
if (_hwnd == IntPtr.Zero) return null;
|
||||
|
||||
if (NativeMethods.GetWindowRect(_hwnd, out var rect))
|
||||
return rect;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsGameFocused()
|
||||
{
|
||||
var fg = NativeMethods.GetForegroundWindow();
|
||||
return fg == _hwnd && _hwnd != IntPtr.Zero;
|
||||
}
|
||||
|
||||
public IntPtr Hwnd => _hwnd;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
public const int SW_RESTORE = 9;
|
||||
public const byte VK_MENU = 0x12;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
|
||||
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
|
||||
public static partial IntPtr FindWindowW(string? lpClassName, string lpWindowName);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool BringWindowToTop(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr GetForegroundWindow();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool IsWindow(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
|
||||
}
|
||||
260
src/Poe2Trade.Inventory/InventoryManager.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.GameLog;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class InventoryManager
|
||||
{
|
||||
private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png");
|
||||
|
||||
public InventoryTracker Tracker { get; } = new();
|
||||
|
||||
private bool _atOwnHideout = true;
|
||||
private string _sellerAccount = "";
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly ClientLogWatcher _logWatcher;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public bool IsAtOwnHideout => _atOwnHideout;
|
||||
public string SellerAccount => _sellerAccount;
|
||||
|
||||
public InventoryManager(GameController game, ScreenReader screen, ClientLogWatcher logWatcher, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_logWatcher = logWatcher;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public void SetLocation(bool atHome, string? seller = null)
|
||||
{
|
||||
_atOwnHideout = atHome;
|
||||
_sellerAccount = seller ?? "";
|
||||
}
|
||||
|
||||
public async Task ScanInventory(PostAction defaultAction = PostAction.Stash)
|
||||
{
|
||||
Log.Information("Scanning inventory...");
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
await _game.OpenInventory();
|
||||
|
||||
var result = await _screen.Grid.Scan("inventory");
|
||||
|
||||
var cells = new bool[5, 12];
|
||||
foreach (var cell in result.Occupied)
|
||||
{
|
||||
if (cell.Row < 5 && cell.Col < 12)
|
||||
cells[cell.Row, cell.Col] = true;
|
||||
}
|
||||
Tracker.InitFromScan(cells, result.Items, defaultAction);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(300);
|
||||
}
|
||||
|
||||
public async Task ClearToStash()
|
||||
{
|
||||
Log.Information("Checking inventory for leftover items...");
|
||||
await ScanInventory(PostAction.Stash);
|
||||
|
||||
if (Tracker.GetItems().Count == 0)
|
||||
{
|
||||
Log.Information("Inventory empty, nothing to clear");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Found {Count} leftover items, depositing to stash", Tracker.GetItems().Count);
|
||||
await DepositItemsToStash(Tracker.GetItems());
|
||||
Tracker.Clear();
|
||||
Log.Information("Inventory cleared to stash");
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureAtOwnHideout()
|
||||
{
|
||||
if (_atOwnHideout)
|
||||
{
|
||||
Log.Information("Already at own hideout");
|
||||
return true;
|
||||
}
|
||||
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
|
||||
var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout());
|
||||
if (!arrived)
|
||||
{
|
||||
Log.Error("Timed out going to own hideout");
|
||||
return false;
|
||||
}
|
||||
|
||||
await Helpers.Sleep(1500);
|
||||
_atOwnHideout = true;
|
||||
_sellerAccount = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task DepositItemsToStash(List<PlacedItem> items)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
|
||||
var stashPos = await FindAndClickNameplate("Stash");
|
||||
if (stashPos == null)
|
||||
{
|
||||
Log.Error("Could not find Stash nameplate");
|
||||
return;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var inventoryLayout = GridLayouts.Inventory;
|
||||
Log.Information("Depositing {Count} items to stash", items.Count);
|
||||
|
||||
await _game.HoldCtrl();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
|
||||
await _game.LeftClickAt(center.X, center.Y);
|
||||
await Helpers.Sleep(150);
|
||||
}
|
||||
await _game.ReleaseCtrl();
|
||||
await Helpers.Sleep(500);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
Log.Information("Items deposited to stash");
|
||||
}
|
||||
|
||||
public async Task<bool> SalvageItems(List<PlacedItem> items)
|
||||
{
|
||||
if (items.Count == 0) return true;
|
||||
|
||||
var nameplate = await FindAndClickNameplate("SALVAGE BENCH");
|
||||
if (nameplate == null)
|
||||
{
|
||||
Log.Error("Could not find Salvage nameplate");
|
||||
return false;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var salvageBtn = await _screen.TemplateMatch(SalvageTemplate);
|
||||
if (salvageBtn != null)
|
||||
{
|
||||
await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y);
|
||||
await Helpers.Sleep(500);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Could not find salvage button via template match");
|
||||
}
|
||||
|
||||
var inventoryLayout = GridLayouts.Inventory;
|
||||
Log.Information("Salvaging {Count} inventory items", items.Count);
|
||||
|
||||
await _game.HoldCtrl();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
|
||||
await _game.LeftClickAt(center.X, center.Y);
|
||||
await Helpers.Sleep(150);
|
||||
}
|
||||
await _game.ReleaseCtrl();
|
||||
await Helpers.Sleep(500);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task ProcessInventory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var home = await EnsureAtOwnHideout();
|
||||
if (!home)
|
||||
{
|
||||
Log.Error("Cannot process inventory: failed to reach hideout");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Tracker.HasItemsWithAction(PostAction.Salvage))
|
||||
{
|
||||
var salvageItems = Tracker.GetItemsByAction(PostAction.Salvage);
|
||||
if (await SalvageItems(salvageItems))
|
||||
Tracker.RemoveItemsByAction(PostAction.Salvage);
|
||||
else
|
||||
Log.Warning("Salvage failed, depositing all to stash");
|
||||
}
|
||||
|
||||
await ScanInventory(PostAction.Stash);
|
||||
|
||||
var allItems = Tracker.GetItems();
|
||||
if (allItems.Count > 0)
|
||||
await DepositItemsToStash(allItems);
|
||||
|
||||
Tracker.Clear();
|
||||
Log.Information("Inventory processing complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Inventory processing failed");
|
||||
try { await _game.PressEscape(); await Helpers.Sleep(300); } catch { }
|
||||
Tracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000)
|
||||
{
|
||||
for (var attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries);
|
||||
var pos = await _screen.FindTextOnScreen(name, fuzzy: true);
|
||||
if (pos.HasValue)
|
||||
{
|
||||
Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y);
|
||||
await _game.LeftClickAt(pos.Value.X, pos.Value.Y);
|
||||
return pos;
|
||||
}
|
||||
if (attempt < maxRetries)
|
||||
await Helpers.Sleep(retryDelayMs);
|
||||
}
|
||||
|
||||
Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
cts.Token.Register(() => tcs.TrySetResult(false));
|
||||
|
||||
void Handler(string _) => tcs.TrySetResult(true);
|
||||
|
||||
_logWatcher.AreaEntered += Handler;
|
||||
try
|
||||
{
|
||||
if (triggerAction != null)
|
||||
{
|
||||
try { await triggerAction(); }
|
||||
catch
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_logWatcher.AreaEntered -= Handler;
|
||||
}
|
||||
}
|
||||
|
||||
public (bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState()
|
||||
{
|
||||
return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells);
|
||||
}
|
||||
}
|
||||
126
src/Poe2Trade.Inventory/InventoryTracker.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class PlacedItem
|
||||
{
|
||||
public int Row { get; init; }
|
||||
public int Col { get; init; }
|
||||
public int W { get; init; }
|
||||
public int H { get; init; }
|
||||
public PostAction PostAction { get; init; }
|
||||
}
|
||||
|
||||
public class InventoryTracker
|
||||
{
|
||||
private const int Rows = 5;
|
||||
private const int Cols = 12;
|
||||
|
||||
private readonly bool[,] _grid = new bool[Rows, Cols];
|
||||
private readonly List<PlacedItem> _items = [];
|
||||
|
||||
public void InitFromScan(bool[,] cells, List<GridItem> items, PostAction defaultAction = PostAction.Stash)
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
|
||||
var rowCount = Math.Min(cells.GetLength(0), Rows);
|
||||
var colCount = Math.Min(cells.GetLength(1), Cols);
|
||||
for (var r = 0; r < rowCount; r++)
|
||||
for (var c = 0; c < colCount; c++)
|
||||
_grid[r, c] = cells[r, c];
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.W > 2 || item.H > 4)
|
||||
{
|
||||
Log.Warning("Ignoring oversized item at ({Row},{Col}) {W}x{H}", item.Row, item.Col, item.W, item.H);
|
||||
continue;
|
||||
}
|
||||
_items.Add(new PlacedItem { Row = item.Row, Col = item.Col, W = item.W, H = item.H, PostAction = defaultAction });
|
||||
}
|
||||
|
||||
Log.Information("Inventory initialized: {Occupied} occupied, {Items} items, {Free} free",
|
||||
Rows * Cols - FreeCells, _items.Count, FreeCells);
|
||||
}
|
||||
|
||||
public (int Row, int Col)? TryPlace(int w, int h, PostAction postAction = PostAction.Stash)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
{
|
||||
if (!Fits(row, col, w, h)) continue;
|
||||
Place(row, col, w, h, postAction);
|
||||
Log.Information("Item placed at ({Row},{Col}) {W}x{H} free={Free}", row, col, w, h, FreeCells);
|
||||
return (row, col);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool CanFit(int w, int h)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
if (Fits(row, col, w, h)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<PlacedItem> GetItems() => [.. _items];
|
||||
public List<PlacedItem> GetItemsByAction(PostAction action) => _items.Where(i => i.PostAction == action).ToList();
|
||||
public bool HasItemsWithAction(PostAction action) => _items.Any(i => i.PostAction == action);
|
||||
|
||||
public void RemoveItem(PlacedItem item)
|
||||
{
|
||||
if (!_items.Remove(item)) return;
|
||||
for (var r = item.Row; r < item.Row + item.H; r++)
|
||||
for (var c = item.Col; c < item.Col + item.W; c++)
|
||||
_grid[r, c] = false;
|
||||
}
|
||||
|
||||
public void RemoveItemsByAction(PostAction action)
|
||||
{
|
||||
var toRemove = _items.Where(i => i.PostAction == action).ToList();
|
||||
foreach (var item in toRemove)
|
||||
RemoveItem(item);
|
||||
Log.Information("Removed {Count} items with action {Action}", toRemove.Count, action);
|
||||
}
|
||||
|
||||
public bool[,] GetGrid() => (bool[,])_grid.Clone();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
Log.Information("Inventory cleared");
|
||||
}
|
||||
|
||||
public int FreeCells
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
for (var r = 0; r < Rows; r++)
|
||||
for (var c = 0; c < Cols; c++)
|
||||
if (!_grid[r, c]) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private bool Fits(int row, int col, int w, int h)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
if (_grid[r, c]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Place(int row, int col, int w, int h, PostAction postAction)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
_grid[r, c] = true;
|
||||
_items.Add(new PlacedItem { Row = row, Col = col, W = w, H = h, PostAction = postAction });
|
||||
}
|
||||
}
|
||||
13
src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
59
src/Poe2Trade.Items/ItemReader.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard.
|
||||
/// Will be wired up to Sidekick's ItemParser once the submodule is added.
|
||||
/// </summary>
|
||||
public class ItemReader
|
||||
{
|
||||
private readonly GameController _game;
|
||||
|
||||
public ItemReader(GameController game)
|
||||
{
|
||||
_game = game;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hover over an item at (x, y), press Ctrl+C, and read clipboard text.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadItemText(int x, int y)
|
||||
{
|
||||
// Move mouse to item position
|
||||
await _game.MoveMouseTo(x, y);
|
||||
await Helpers.Sleep(100);
|
||||
|
||||
// Ctrl+C to copy item text
|
||||
ClipboardHelper.Write(""); // Clear clipboard
|
||||
await Helpers.Sleep(50);
|
||||
|
||||
// Press Ctrl+C
|
||||
var input = new InputSender();
|
||||
await input.KeyDown(InputSender.VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await input.PressKey(InputSender.VK.C);
|
||||
await input.KeyUp(InputSender.VK.CONTROL);
|
||||
await Helpers.Sleep(100);
|
||||
|
||||
var text = ClipboardHelper.Read();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
Log.Warning("No item text in clipboard after Ctrl+C at ({X},{Y})", x, y);
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.Information("Read item text ({Length} chars) from ({X},{Y})", text.Length, x, y);
|
||||
return text;
|
||||
}
|
||||
|
||||
// TODO: Wire up Sidekick's ItemParser
|
||||
// public async Task<ParsedItem?> ParseItem(int x, int y)
|
||||
// {
|
||||
// var text = await ReadItemText(x, y);
|
||||
// if (text == null) return null;
|
||||
// return SidekickItemParser.Parse(text);
|
||||
// }
|
||||
}
|
||||
11
src/Poe2Trade.Items/Poe2Trade.Items.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
171
src/Poe2Trade.Screen/DaemonTypes.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class OcrWord
|
||||
{
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("x")] public int X { get; set; }
|
||||
[JsonPropertyName("y")] public int Y { get; set; }
|
||||
[JsonPropertyName("width")] public int Width { get; set; }
|
||||
[JsonPropertyName("height")] public int Height { get; set; }
|
||||
}
|
||||
|
||||
public class OcrLine
|
||||
{
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("words")] public List<OcrWord> Words { get; set; } = [];
|
||||
}
|
||||
|
||||
public class OcrResponse
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public List<OcrLine> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
public class GridItem
|
||||
{
|
||||
[JsonPropertyName("row")] public int Row { get; set; }
|
||||
[JsonPropertyName("col")] public int Col { get; set; }
|
||||
[JsonPropertyName("w")] public int W { get; set; }
|
||||
[JsonPropertyName("h")] public int H { get; set; }
|
||||
}
|
||||
|
||||
public class GridMatch
|
||||
{
|
||||
[JsonPropertyName("row")] public int Row { get; set; }
|
||||
[JsonPropertyName("col")] public int Col { get; set; }
|
||||
[JsonPropertyName("similarity")] public double Similarity { get; set; }
|
||||
}
|
||||
|
||||
public class GridScanResult
|
||||
{
|
||||
public bool[][] Cells { get; set; } = [];
|
||||
public List<GridItem> Items { get; set; } = [];
|
||||
public List<GridMatch>? Matches { get; set; }
|
||||
}
|
||||
|
||||
public class DiffOcrResponse
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public List<OcrLine> Lines { get; set; } = [];
|
||||
public Poe2Trade.Core.Region? Region { get; set; }
|
||||
}
|
||||
|
||||
public class TemplateMatchResult
|
||||
{
|
||||
public int X { get; set; }
|
||||
public int Y { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
// -- Parameter types --
|
||||
|
||||
public sealed class DiffCropParams
|
||||
{
|
||||
[JsonPropertyName("diffThresh")]
|
||||
public int DiffThresh { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.4;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class OcrParams
|
||||
{
|
||||
// preprocessing
|
||||
[JsonPropertyName("kernelSize")]
|
||||
public int KernelSize { get; set; } = 41;
|
||||
|
||||
[JsonPropertyName("upscale")]
|
||||
public int Upscale { get; set; } = 2;
|
||||
|
||||
[JsonPropertyName("useBackgroundSub")]
|
||||
public bool UseBackgroundSub { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("dimPercentile")]
|
||||
public int DimPercentile { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("textThresh")]
|
||||
public int TextThresh { get; set; } = 60;
|
||||
|
||||
[JsonPropertyName("softThreshold")]
|
||||
public bool SoftThreshold { get; set; } = false;
|
||||
|
||||
// EasyOCR tuning
|
||||
[JsonPropertyName("mergeGap")]
|
||||
public int MergeGap { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("linkThreshold")]
|
||||
public double? LinkThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("textThreshold")]
|
||||
public double? TextThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("lowText")]
|
||||
public double? LowText { get; set; }
|
||||
|
||||
[JsonPropertyName("widthThs")]
|
||||
public double? WidthThs { get; set; }
|
||||
|
||||
[JsonPropertyName("paragraph")]
|
||||
public bool? Paragraph { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DiffOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public DiffCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EdgeCropParams
|
||||
{
|
||||
[JsonPropertyName("darkThresh")]
|
||||
public int DarkThresh { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("minDarkRun")]
|
||||
public int MinDarkRun { get; set; } = 200;
|
||||
|
||||
[JsonPropertyName("runGapTolerance")]
|
||||
public int RunGapTolerance { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.3;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class EdgeOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public EdgeCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
}
|
||||
|
|
@ -1,21 +1,17 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class DetectGridHandler
|
||||
{
|
||||
public object HandleDetectGrid(Request req)
|
||||
public DetectGridResult Detect(Region region, int minCellSize = 20, int maxCellSize = 70,
|
||||
string? file = null, bool debug = false)
|
||||
{
|
||||
if (req.Region == null)
|
||||
return new ErrorResponse("detect-grid requires region");
|
||||
|
||||
int minCell = req.MinCellSize > 0 ? req.MinCellSize : 20;
|
||||
int maxCell = req.MaxCellSize > 0 ? req.MaxCellSize : 70;
|
||||
bool debug = req.Debug;
|
||||
|
||||
Bitmap bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
Bitmap bitmap = ScreenCapture.CaptureOrLoad(file, region);
|
||||
int w = bitmap.Width;
|
||||
int h = bitmap.Height;
|
||||
|
||||
|
|
@ -39,20 +35,15 @@ class DetectGridHandler
|
|||
|
||||
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
|
||||
const int veryDarkPixelThresh = 12;
|
||||
const double gridSegThresh = 0.25;
|
||||
|
||||
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++)
|
||||
{
|
||||
|
|
@ -64,43 +55,30 @@ class DetectGridHandler
|
|||
darkDensity[x] = (double)count / bandH;
|
||||
}
|
||||
|
||||
// Find segments where density > gridSegThresh (grid panel regions)
|
||||
var gridSegs = SignalProcessing.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) = SignalProcessing.FindPeriodWithScore(segment, minCell, maxCell);
|
||||
|
||||
var (period, acScore) = SignalProcessing.FindPeriodWithScore(segment, minCellSize, maxCellSize);
|
||||
if (period <= 0) continue;
|
||||
|
||||
// FindGridExtent within the segment
|
||||
var (extLeft, extRight) = SignalProcessing.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);
|
||||
|
|
@ -108,13 +86,12 @@ class DetectGridHandler
|
|||
return sb.CompareTo(sa);
|
||||
});
|
||||
|
||||
// ── Pass 2: Verify vertical periodicity ──
|
||||
// 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++)
|
||||
{
|
||||
|
|
@ -126,17 +103,15 @@ class DetectGridHandler
|
|||
rowDensity[y] = (double)count / colSpan;
|
||||
}
|
||||
|
||||
// Find grid panel vertical segment
|
||||
var vGridSegs = SignalProcessing.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) = SignalProcessing.FindPeriodWithScore(vSegment, minCell, maxCell);
|
||||
var (cellH, vAc) = SignalProcessing.FindPeriodWithScore(vSegment, minCellSize, maxCellSize);
|
||||
if (cellH <= 0) continue;
|
||||
|
||||
var (extTop, extBottom) = SignalProcessing.FindGridExtent(vSegment, cellH);
|
||||
|
|
@ -146,37 +121,20 @@ class DetectGridHandler
|
|||
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}");
|
||||
|
||||
return new DetectGridResponse
|
||||
return new DetectGridResult
|
||||
{
|
||||
Detected = true,
|
||||
Region = new RegionRect
|
||||
{
|
||||
X = req.Region.X + cand.hLeft,
|
||||
Y = req.Region.Y + top,
|
||||
Width = gridW,
|
||||
Height = gridH,
|
||||
},
|
||||
Region = new Region(region.X + cand.hLeft, region.Y + top, gridW, gridH),
|
||||
Cols = cols,
|
||||
Rows = rows,
|
||||
CellWidth = Math.Round((double)gridW / cols, 1),
|
||||
|
|
@ -184,7 +142,16 @@ class DetectGridHandler
|
|||
};
|
||||
}
|
||||
|
||||
if (debug) Console.Error.WriteLine(" No valid 2D grid found");
|
||||
return new DetectGridResponse { Detected = false };
|
||||
return new DetectGridResult { Detected = false };
|
||||
}
|
||||
}
|
||||
|
||||
public class DetectGridResult
|
||||
{
|
||||
public bool Detected { get; set; }
|
||||
public Region? Region { get; set; }
|
||||
public int Cols { get; set; }
|
||||
public int Rows { get; set; }
|
||||
public double CellWidth { get; set; }
|
||||
public double CellHeight { get; set; }
|
||||
}
|
||||
368
src/Poe2Trade.Screen/DiffCropHandler.cs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class DiffCropHandler
|
||||
{
|
||||
private Bitmap? _referenceFrame;
|
||||
private Region? _referenceRegion;
|
||||
|
||||
public void HandleSnapshot(string? file = null, Region? region = null)
|
||||
{
|
||||
_referenceFrame?.Dispose();
|
||||
_referenceFrame = ScreenCapture.CaptureOrLoad(file, region);
|
||||
_referenceRegion = region;
|
||||
}
|
||||
|
||||
public void HandleScreenshot(string path, Region? region = null)
|
||||
{
|
||||
var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(null, region);
|
||||
var format = ImageUtils.GetImageFormat(path);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
bitmap.Save(path, format);
|
||||
if (bitmap != _referenceFrame) bitmap.Dispose();
|
||||
}
|
||||
|
||||
public byte[] HandleCapture(Region? region = null)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff detection + crop only. Returns the raw tooltip crop bitmap and region,
|
||||
/// or null if no tooltip detected. Caller is responsible for disposing the bitmaps.
|
||||
/// </summary>
|
||||
public (Bitmap cropped, Bitmap refCropped, Bitmap current, Region region)? DiffCrop(
|
||||
DiffCropParams c, string? file = null, Region? region = null)
|
||||
{
|
||||
if (_referenceFrame == null)
|
||||
return null;
|
||||
|
||||
var diffRegion = region ?? _referenceRegion;
|
||||
int baseX = diffRegion?.X ?? 0;
|
||||
int baseY = diffRegion?.Y ?? 0;
|
||||
var current = ScreenCapture.CaptureOrLoad(file, diffRegion);
|
||||
|
||||
Bitmap refForDiff = _referenceFrame;
|
||||
bool disposeRef = false;
|
||||
|
||||
if (diffRegion != null)
|
||||
{
|
||||
if (_referenceRegion == null)
|
||||
{
|
||||
var croppedRef = CropBitmap(_referenceFrame, diffRegion);
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
else if (!RegionsEqual(diffRegion, _referenceRegion))
|
||||
{
|
||||
int offX = diffRegion.X - _referenceRegion.X;
|
||||
int offY = diffRegion.Y - _referenceRegion.Y;
|
||||
if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
var croppedRef = CropBitmap(_referenceFrame, new Region(offX, offY, diffRegion.Width, diffRegion.Height));
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
}
|
||||
|
||||
int w = Math.Min(refForDiff.Width, current.Width);
|
||||
int h = Math.Min(refForDiff.Height, current.Height);
|
||||
|
||||
var refData = refForDiff.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] refPx = new byte[refData.Stride * h];
|
||||
Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length);
|
||||
refForDiff.UnlockBits(refData);
|
||||
int stride = refData.Stride;
|
||||
|
||||
var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] curPx = new byte[curData.Stride * h];
|
||||
Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length);
|
||||
current.UnlockBits(curData);
|
||||
|
||||
int diffThresh = c.DiffThresh;
|
||||
|
||||
// Pass 1: parallel row diff
|
||||
int[] rowCounts = new int[h];
|
||||
Parallel.For(0, h, y =>
|
||||
{
|
||||
int count = 0;
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
count++;
|
||||
}
|
||||
rowCounts[y] = count;
|
||||
});
|
||||
|
||||
int totalChanged = 0;
|
||||
for (int y = 0; y < h; y++) totalChanged += rowCounts[y];
|
||||
|
||||
if (totalChanged == 0)
|
||||
{
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int maxGap = c.MaxGap;
|
||||
int rowThresh = w / c.RowThreshDiv;
|
||||
int bestRowStart = 0, bestRowEnd = 0, bestRowLen = 0;
|
||||
int curRowStart = -1, lastActiveRow = -1;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
if (rowCounts[y] >= rowThresh)
|
||||
{
|
||||
if (curRowStart < 0) curRowStart = y;
|
||||
lastActiveRow = y;
|
||||
}
|
||||
else if (curRowStart >= 0 && y - lastActiveRow > maxGap)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
curRowStart = -1;
|
||||
}
|
||||
}
|
||||
if (curRowStart >= 0)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
}
|
||||
|
||||
// Pass 2: parallel column diff
|
||||
int[] colCounts = new int[w];
|
||||
int rowRangeLen = bestRowEnd - bestRowStart + 1;
|
||||
if (rowRangeLen <= 200)
|
||||
{
|
||||
for (int y = bestRowStart; y <= bestRowEnd; y++)
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
colCounts[x]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Parallel.For(bestRowStart, bestRowEnd + 1,
|
||||
() => new int[w],
|
||||
(y, _, localCols) =>
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
localCols[x]++;
|
||||
}
|
||||
return localCols;
|
||||
},
|
||||
localCols =>
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
Interlocked.Add(ref colCounts[x], localCols[x]);
|
||||
});
|
||||
}
|
||||
|
||||
int tooltipHeight = bestRowEnd - bestRowStart + 1;
|
||||
int colThresh = tooltipHeight / c.ColThreshDiv;
|
||||
|
||||
int bestColStart = 0, bestColEnd = 0, bestColLen = 0;
|
||||
int curColStart = -1, lastActiveCol = -1;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
if (colCounts[x] >= colThresh)
|
||||
{
|
||||
if (curColStart < 0) curColStart = x;
|
||||
lastActiveCol = x;
|
||||
}
|
||||
else if (curColStart >= 0 && x - lastActiveCol > maxGap)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
curColStart = -1;
|
||||
}
|
||||
}
|
||||
if (curColStart >= 0)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
}
|
||||
|
||||
Log.Debug("diff-crop: changed={Changed} rows={RowStart}-{RowEnd}({RowLen}) cols={ColStart}-{ColEnd}({ColLen})",
|
||||
totalChanged, bestRowStart, bestRowEnd, bestRowLen, bestColStart, bestColEnd, bestColLen);
|
||||
|
||||
if (bestRowLen < 50 || bestColLen < 50)
|
||||
{
|
||||
Log.Debug("diff-crop: no tooltip-sized region found");
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int minX = bestColStart;
|
||||
int minY = bestRowStart;
|
||||
int maxX = Math.Min(bestColEnd, w - 1);
|
||||
int maxY = Math.Min(bestRowEnd, h - 1);
|
||||
|
||||
// Boundary extension
|
||||
int extRowThresh = Math.Max(1, rowThresh / 4);
|
||||
int extColThresh = Math.Max(1, colThresh / 4);
|
||||
|
||||
int extTop = Math.Max(0, minY - maxGap);
|
||||
for (int y = minY - 1; y >= extTop; y--)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) minY = y;
|
||||
else break;
|
||||
}
|
||||
int extBottom = Math.Min(h - 1, maxY + maxGap);
|
||||
for (int y = maxY + 1; y <= extBottom; y++)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) maxY = y;
|
||||
else break;
|
||||
}
|
||||
int extLeft = Math.Max(0, minX - maxGap);
|
||||
for (int x = minX - 1; x >= extLeft; x--)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) minX = x;
|
||||
else break;
|
||||
}
|
||||
int extRight = Math.Min(w - 1, maxX + maxGap);
|
||||
for (int x = maxX + 1; x <= extRight; x++)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) maxX = x;
|
||||
else break;
|
||||
}
|
||||
|
||||
// Trim low-density edges
|
||||
int colSpan = maxX - minX + 1;
|
||||
if (colSpan > 50)
|
||||
{
|
||||
int q1 = minX + colSpan / 4;
|
||||
int q3 = minX + colSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int x = q1; x <= q3; x++) { midSum += colCounts[x]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minX < maxX - 50 && colCounts[minX] < cutoff)
|
||||
minX++;
|
||||
while (maxX > minX + 50 && colCounts[maxX] < cutoff)
|
||||
maxX--;
|
||||
}
|
||||
|
||||
int rowSpan = maxY - minY + 1;
|
||||
if (rowSpan > 50)
|
||||
{
|
||||
int q1 = minY + rowSpan / 4;
|
||||
int q3 = minY + rowSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int y = q1; y <= q3; y++) { midSum += rowCounts[y]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minY < maxY - 50 && rowCounts[minY] < cutoff)
|
||||
minY++;
|
||||
while (maxY > minY + 50 && rowCounts[maxY] < cutoff)
|
||||
maxY--;
|
||||
}
|
||||
int rw = maxX - minX + 1;
|
||||
int rh = maxY - minY + 1;
|
||||
|
||||
var cropped = CropFromBytes(curPx, stride, minX, minY, rw, rh);
|
||||
var refCropped = CropFromBytes(refPx, stride, minX, minY, rw, rh);
|
||||
var resultRegion = new Region(baseX + minX, baseY + minY, rw, rh);
|
||||
|
||||
Log.Debug("diff-crop: tooltip region ({X},{Y}) {W}x{H}", minX, minY, rw, rh);
|
||||
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return (cropped, refCropped, current, resultRegion);
|
||||
}
|
||||
|
||||
private static bool RegionsEqual(Region a, Region b) =>
|
||||
a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
|
||||
|
||||
private static Bitmap? CropBitmap(Bitmap src, Region region)
|
||||
{
|
||||
int cx = Math.Max(0, region.X);
|
||||
int cy = Math.Max(0, region.Y);
|
||||
int cw = Math.Min(region.Width, src.Width - cx);
|
||||
int ch = Math.Min(region.Height, src.Height - cy);
|
||||
if (cw <= 0 || ch <= 0)
|
||||
return null;
|
||||
return src.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast crop from raw pixel bytes.
|
||||
/// </summary>
|
||||
private static Bitmap CropFromBytes(byte[] px, int srcStride, int cropX, int cropY, int cropW, int cropH)
|
||||
{
|
||||
var bmp = new Bitmap(cropW, cropH, PixelFormat.Format32bppArgb);
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, cropW, cropH), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
|
||||
int dstStride = data.Stride;
|
||||
int rowBytes = cropW * 4;
|
||||
for (int y = 0; y < cropH; y++)
|
||||
{
|
||||
int srcOffset = (cropY + y) * srcStride + cropX * 4;
|
||||
Marshal.Copy(px, srcOffset, data.Scan0 + y * dstStride, rowBytes);
|
||||
}
|
||||
bmp.UnlockBits(data);
|
||||
return bmp;
|
||||
}
|
||||
|
||||
public static double LevenshteinSimilarity(string a, string b)
|
||||
{
|
||||
a = a.ToLowerInvariant();
|
||||
b = b.ToLowerInvariant();
|
||||
if (a == b) return 1.0;
|
||||
|
||||
int la = a.Length, lb = b.Length;
|
||||
if (la == 0 || lb == 0) return 0.0;
|
||||
|
||||
var d = new int[la + 1, lb + 1];
|
||||
for (int i = 0; i <= la; i++) d[i, 0] = i;
|
||||
for (int j = 0; j <= lb; j++) d[0, j] = j;
|
||||
|
||||
for (int i = 1; i <= la; i++)
|
||||
for (int j = 1; j <= lb; j++)
|
||||
{
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
|
||||
}
|
||||
|
||||
return 1.0 - (double)d[la, lb] / Math.Max(la, lb);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class EdgeCropHandler
|
||||
{
|
||||
|
|
@ -12,27 +14,28 @@ class EdgeCropHandler
|
|||
[DllImport("user32.dll")]
|
||||
private static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
public (Bitmap cropped, Bitmap fullCapture, RegionRect region)? EdgeCrop(Request req, EdgeCropParams p)
|
||||
public (Bitmap cropped, Bitmap fullCapture, Region region)? EdgeCrop(
|
||||
EdgeCropParams p, int? cursorX = null, int? cursorY = null, string? file = null)
|
||||
{
|
||||
int cursorX, cursorY;
|
||||
if (req.CursorX.HasValue && req.CursorY.HasValue)
|
||||
int cx, cy;
|
||||
if (cursorX.HasValue && cursorY.HasValue)
|
||||
{
|
||||
cursorX = req.CursorX.Value;
|
||||
cursorY = req.CursorY.Value;
|
||||
cx = cursorX.Value;
|
||||
cy = cursorY.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
GetCursorPos(out var pt);
|
||||
cursorX = pt.X;
|
||||
cursorY = pt.Y;
|
||||
cx = pt.X;
|
||||
cy = pt.Y;
|
||||
}
|
||||
|
||||
var fullCapture = ScreenCapture.CaptureOrLoad(req.File, null);
|
||||
var fullCapture = ScreenCapture.CaptureOrLoad(file, null);
|
||||
int w = fullCapture.Width;
|
||||
int h = fullCapture.Height;
|
||||
|
||||
cursorX = Math.Clamp(cursorX, 0, w - 1);
|
||||
cursorY = Math.Clamp(cursorY, 0, h - 1);
|
||||
cx = Math.Clamp(cx, 0, w - 1);
|
||||
cy = Math.Clamp(cy, 0, h - 1);
|
||||
|
||||
var bmpData = fullCapture.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] px = new byte[bmpData.Stride * h];
|
||||
|
|
@ -44,12 +47,10 @@ class EdgeCropHandler
|
|||
int colGap = p.RunGapTolerance;
|
||||
int maxGap = p.MaxGap;
|
||||
|
||||
// ── Phase 1: Per-row horizontal extent ──
|
||||
// Scan left/right from cursorX per row. Gap tolerance bridges through text.
|
||||
// Percentile-based filtering for robustness.
|
||||
int bandHalf = p.MinDarkRun; // repurpose: half-height of horizontal scan band
|
||||
int bandTop = Math.Max(0, cursorY - bandHalf);
|
||||
int bandBot = Math.Min(h - 1, cursorY + bandHalf);
|
||||
// Phase 1: Per-row horizontal extent
|
||||
int bandHalf = p.MinDarkRun;
|
||||
int bandTop = Math.Max(0, cy - bandHalf);
|
||||
int bandBot = Math.Min(h - 1, cy + bandHalf);
|
||||
|
||||
var leftExtents = new List<int>();
|
||||
var rightExtents = new List<int>();
|
||||
|
|
@ -57,27 +58,30 @@ class EdgeCropHandler
|
|||
for (int y = bandTop; y <= bandBot; y++)
|
||||
{
|
||||
int rowOff = y * stride;
|
||||
int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cursorX, darkThresh, seedRadius: 6);
|
||||
int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cx, darkThresh, seedRadius: 6);
|
||||
if (seedX < 0) continue;
|
||||
|
||||
int leftEdge = seedX;
|
||||
int leftEdge = cx;
|
||||
int gap = 0;
|
||||
for (int x = seedX - 1; x >= 0; x--)
|
||||
bool foundLeft = false;
|
||||
int initialBridge = Math.Max(colGap * 4, 12);
|
||||
for (int x = cx; x >= 0; x--)
|
||||
{
|
||||
int i = rowOff + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { leftEdge = x; gap = 0; }
|
||||
else if (++gap > colGap) break;
|
||||
if (brightness < darkThresh) { leftEdge = x; gap = 0; foundLeft = true; }
|
||||
else if (++gap > (foundLeft ? colGap : initialBridge)) break;
|
||||
}
|
||||
|
||||
int rightEdge = seedX;
|
||||
int rightEdge = cx;
|
||||
gap = 0;
|
||||
for (int x = seedX + 1; x < w; x++)
|
||||
bool foundRight = false;
|
||||
for (int x = cx; x < w; x++)
|
||||
{
|
||||
int i = rowOff + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { rightEdge = x; gap = 0; }
|
||||
else if (++gap > colGap) break;
|
||||
if (brightness < darkThresh) { rightEdge = x; gap = 0; foundRight = true; }
|
||||
else if (++gap > (foundRight ? colGap : initialBridge)) break;
|
||||
}
|
||||
|
||||
leftExtents.Add(leftEdge);
|
||||
|
|
@ -86,7 +90,7 @@ class EdgeCropHandler
|
|||
|
||||
if (leftExtents.Count < 10)
|
||||
{
|
||||
Console.Error.WriteLine($" edge-crop: too few dark rows ({leftExtents.Count})");
|
||||
Log.Debug("edge-crop: too few dark rows ({Count})", leftExtents.Count);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
|
@ -94,8 +98,6 @@ class EdgeCropHandler
|
|||
leftExtents.Sort();
|
||||
rightExtents.Sort();
|
||||
|
||||
// Use RowThreshDiv/ColThreshDiv as percentile denominators
|
||||
// e.g., RowThreshDiv=4 → 25th percentile for left, ColThreshDiv=4 → 75th for right
|
||||
int leftPctIdx = leftExtents.Count / p.RowThreshDiv;
|
||||
int rightPctIdx = rightExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv;
|
||||
leftPctIdx = Math.Clamp(leftPctIdx, 0, leftExtents.Count - 1);
|
||||
|
|
@ -104,49 +106,50 @@ class EdgeCropHandler
|
|||
int bestColStart = leftExtents[leftPctIdx];
|
||||
int bestColEnd = rightExtents[rightPctIdx];
|
||||
|
||||
Console.Error.WriteLine($" edge-crop: horizontal: left={bestColStart} right={bestColEnd} ({bestColEnd - bestColStart + 1}px) samples={leftExtents.Count} pctL={leftPctIdx}/{leftExtents.Count} pctR={rightPctIdx}/{rightExtents.Count}");
|
||||
|
||||
if (bestColEnd - bestColStart + 1 < 50)
|
||||
{
|
||||
Console.Error.WriteLine($" edge-crop: horizontal extent too small");
|
||||
Log.Debug("edge-crop: horizontal extent too small");
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Phase 2: Per-column vertical extent ──
|
||||
// Phase 2: Per-column vertical extent
|
||||
int colBandHalf = (bestColEnd - bestColStart + 1) / 3;
|
||||
int colBandLeft = Math.Max(bestColStart, cursorX - colBandHalf);
|
||||
int colBandRight = Math.Min(bestColEnd, cursorX + colBandHalf);
|
||||
int colBandLeft = Math.Max(bestColStart, cx - colBandHalf);
|
||||
int colBandRight = Math.Min(bestColEnd, cx + colBandHalf);
|
||||
|
||||
var topExtents = new List<int>();
|
||||
var bottomExtents = new List<int>();
|
||||
|
||||
// Asymmetric gap: larger upward to bridge header decorations (~30-40px bright)
|
||||
int maxGapUp = maxGap * 3;
|
||||
|
||||
for (int x = colBandLeft; x <= colBandRight; x++)
|
||||
{
|
||||
int seedY = FindDarkSeedInColumn(px, stride, h, x, cursorY, darkThresh, seedRadius: 6);
|
||||
int seedY = FindDarkSeedInColumn(px, stride, h, x, cy, darkThresh, seedRadius: 6);
|
||||
if (seedY < 0) continue;
|
||||
|
||||
int topEdge = seedY;
|
||||
int topEdge = cy;
|
||||
int gap = 0;
|
||||
for (int y = seedY - 1; y >= 0; y--)
|
||||
bool foundTop = false;
|
||||
int initialBridgeUp = Math.Max(maxGapUp * 2, 12);
|
||||
for (int y = cy; y >= 0; y--)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { topEdge = y; gap = 0; }
|
||||
else if (++gap > maxGapUp) break;
|
||||
if (brightness < darkThresh) { topEdge = y; gap = 0; foundTop = true; }
|
||||
else if (++gap > (foundTop ? maxGapUp : initialBridgeUp)) break;
|
||||
}
|
||||
|
||||
int bottomEdge = seedY;
|
||||
int bottomEdge = cy;
|
||||
gap = 0;
|
||||
for (int y = seedY + 1; y < h; y++)
|
||||
bool foundBottom = false;
|
||||
int initialBridgeDown = Math.Max(maxGap * 2, 12);
|
||||
for (int y = cy; y < h; y++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { bottomEdge = y; gap = 0; }
|
||||
else if (++gap > maxGap) break;
|
||||
if (brightness < darkThresh) { bottomEdge = y; gap = 0; foundBottom = true; }
|
||||
else if (++gap > (foundBottom ? maxGap : initialBridgeDown)) break;
|
||||
}
|
||||
|
||||
topExtents.Add(topEdge);
|
||||
|
|
@ -155,7 +158,7 @@ class EdgeCropHandler
|
|||
|
||||
if (topExtents.Count < 10)
|
||||
{
|
||||
Console.Error.WriteLine($" edge-crop: too few dark columns ({topExtents.Count})");
|
||||
Log.Debug("edge-crop: too few dark columns ({Count})", topExtents.Count);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
|
@ -171,11 +174,9 @@ class EdgeCropHandler
|
|||
int bestRowStart = topExtents[topPctIdx];
|
||||
int bestRowEnd = bottomExtents[botPctIdx];
|
||||
|
||||
Console.Error.WriteLine($" edge-crop: vertical: top={bestRowStart} bottom={bestRowEnd} ({bestRowEnd - bestRowStart + 1}px) samples={topExtents.Count}");
|
||||
|
||||
if (bestRowEnd - bestRowStart + 1 < 50)
|
||||
{
|
||||
Console.Error.WriteLine($" edge-crop: vertical extent too small");
|
||||
Log.Debug("edge-crop: vertical extent too small");
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
|
@ -188,18 +189,16 @@ class EdgeCropHandler
|
|||
int rw = maxX - minX + 1;
|
||||
int rh = maxY - minY + 1;
|
||||
|
||||
Console.Error.WriteLine($" edge-crop: result ({minX},{minY}) {rw}x{rh}");
|
||||
|
||||
if (rw < 50 || rh < 50)
|
||||
{
|
||||
Console.Error.WriteLine($" edge-crop: region too small ({rw}x{rh})");
|
||||
Log.Debug("edge-crop: region too small ({W}x{H})", rw, rh);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
var cropRect = new Rectangle(minX, minY, rw, rh);
|
||||
var cropped = fullCapture.Clone(cropRect, PixelFormat.Format32bppArgb);
|
||||
var region = new RegionRect { X = minX, Y = minY, Width = rw, Height = rh };
|
||||
var region = new Region(minX, minY, rw, rh);
|
||||
|
||||
return (cropped, fullCapture, region);
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class GridHandler
|
||||
public class GridHandler
|
||||
{
|
||||
// Pre-loaded empty cell templates (loaded lazily on first grid scan)
|
||||
private byte[]? _emptyTemplate70Gray;
|
||||
private byte[]? _emptyTemplate70Argb;
|
||||
private int _emptyTemplate70W, _emptyTemplate70H, _emptyTemplate70Stride;
|
||||
|
|
@ -14,16 +13,13 @@ class GridHandler
|
|||
private byte[]? _emptyTemplate35Argb;
|
||||
private int _emptyTemplate35W, _emptyTemplate35H, _emptyTemplate35Stride;
|
||||
|
||||
public object HandleGrid(Request req)
|
||||
public GridScanResult Scan(Region region, int cols, int rows,
|
||||
int threshold = 0, int? targetRow = null, int? targetCol = null,
|
||||
string? file = null, bool debug = false)
|
||||
{
|
||||
if (req.Region == null || req.Cols <= 0 || req.Rows <= 0)
|
||||
return new ErrorResponse("grid command requires region, cols, rows");
|
||||
|
||||
LoadTemplatesIfNeeded();
|
||||
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
int cols = req.Cols;
|
||||
int rows = req.Rows;
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(file, region);
|
||||
float cellW = (float)bitmap.Width / cols;
|
||||
float cellH = (float)bitmap.Height / rows;
|
||||
|
||||
|
|
@ -50,14 +46,12 @@ class GridHandler
|
|||
}
|
||||
else
|
||||
{
|
||||
return new ErrorResponse("Empty cell templates not found in assets/");
|
||||
throw new InvalidOperationException("Empty cell templates not found in assets/");
|
||||
}
|
||||
|
||||
// Convert captured bitmap to grayscale + keep ARGB for border color comparison
|
||||
var (captureGray, captureArgb, captureStride) = ImageUtils.BitmapToGrayAndArgb(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
|
||||
|
|
@ -71,17 +65,15 @@ class GridHandler
|
|||
}
|
||||
double tmplMean = innerCount > 0 ? (double)templateSum / innerCount : 0;
|
||||
|
||||
// Threshold for brightness-normalized MAD
|
||||
double diffThreshold = req.Threshold > 0 ? req.Threshold : 5;
|
||||
bool debug = req.Debug;
|
||||
double diffThreshold = threshold > 0 ? threshold : 5;
|
||||
|
||||
if (debug) Console.Error.WriteLine($"Grid: {cols}x{rows}, cellW={cellW:F1}, cellH={cellH:F1}, border={border}, threshold={diffThreshold}, tmplMean={tmplMean:F1}");
|
||||
if (debug) Log.Debug("Grid: {Cols}x{Rows}, cellW={CellW:F1}, cellH={CellH:F1}, border={Border}, threshold={Threshold}, tmplMean={TmplMean:F1}",
|
||||
cols, rows, cellW, cellH, border, diffThreshold, tmplMean);
|
||||
|
||||
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);
|
||||
|
|
@ -92,7 +84,6 @@ class GridHandler
|
|||
int innerW = Math.Min(cw, templateW) - border;
|
||||
int innerH = Math.Min(ch, templateH) - border;
|
||||
|
||||
// First pass: compute cell region mean brightness
|
||||
long cellSum = 0;
|
||||
int compared = 0;
|
||||
for (int py = border; py < innerH; py++)
|
||||
|
|
@ -104,7 +95,6 @@ class GridHandler
|
|||
double cellMean = compared > 0 ? (double)cellSum / compared : 0;
|
||||
double offset = cellMean - tmplMean;
|
||||
|
||||
// Second pass: MAD on brightness-normalized values
|
||||
long diffSum = 0;
|
||||
for (int py = border; py < innerH; py++)
|
||||
for (int px = border; px < innerW; px++)
|
||||
|
|
@ -116,16 +106,11 @@ class GridHandler
|
|||
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)}");
|
||||
}
|
||||
|
||||
// ── Item detection: compare border pixels to empty template (grayscale) ──
|
||||
// Items have a colored tint behind them that shows through grid lines.
|
||||
// Compare each cell's border strip against the template's border pixels.
|
||||
// If they differ → item tint present → cells belong to same item.
|
||||
// Item detection: union-find on border pixel comparison
|
||||
int[] parent = new int[rows * cols];
|
||||
for (int i = 0; i < parent.Length; i++) parent[i] = i;
|
||||
|
||||
|
|
@ -164,7 +149,6 @@ class GridHandler
|
|||
}
|
||||
}
|
||||
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
|
||||
if (debug) Console.Error.WriteLine($" H ({row},{col})->({row},{col+1}): {meanDiff:F1}{(meanDiff > borderDiffThresh ? " SAME" : "")}");
|
||||
if (meanDiff > borderDiffThresh)
|
||||
Union(row * cols + col, row * cols + col + 1);
|
||||
}
|
||||
|
|
@ -189,7 +173,6 @@ class GridHandler
|
|||
}
|
||||
}
|
||||
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
|
||||
if (debug) Console.Error.WriteLine($" V ({row},{col})->({row+1},{col}): {meanDiff:F1}{(meanDiff > borderDiffThresh ? " SAME" : "")}");
|
||||
if (meanDiff > borderDiffThresh)
|
||||
Union(row * cols + col, (row + 1) * cols + col);
|
||||
}
|
||||
|
|
@ -217,31 +200,24 @@ class GridHandler
|
|||
items.Add(new GridItem { Row = minR, Col = minC, W = maxC - minC + 1, H = maxR - minR + 1 });
|
||||
}
|
||||
|
||||
if (debug)
|
||||
{
|
||||
Console.Error.WriteLine($" Items found: {items.Count}");
|
||||
foreach (var item in items)
|
||||
Console.Error.WriteLine($" ({item.Row},{item.Col}) {item.W}x{item.H}");
|
||||
}
|
||||
|
||||
// ── Visual matching: find cells similar to target ──
|
||||
// Visual matching
|
||||
List<GridMatch>? matches = null;
|
||||
if (req.TargetRow >= 0 && req.TargetCol >= 0 &&
|
||||
req.TargetRow < rows && req.TargetCol < cols &&
|
||||
cells[req.TargetRow][req.TargetCol])
|
||||
int tRow = targetRow ?? -1;
|
||||
int tCol = targetCol ?? -1;
|
||||
if (tRow >= 0 && tCol >= 0 && tRow < rows && tCol < cols && cells[tRow][tCol])
|
||||
{
|
||||
matches = FindMatchingCells(
|
||||
captureGray, captureW, bitmap.Height,
|
||||
cells, rows, cols, cellW, cellH, border,
|
||||
req.TargetRow, req.TargetCol, debug);
|
||||
tRow, tCol, debug);
|
||||
}
|
||||
|
||||
return new GridResponse { Cells = cells, Items = items, Matches = matches };
|
||||
// Convert cells to bool[][]
|
||||
var cellsArr = cells.Select(r => r.ToArray()).ToArray();
|
||||
|
||||
return new GridScanResult { Cells = cellsArr, Items = items, Matches = matches };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find all occupied cells visually similar to the target cell using full-resolution NCC.
|
||||
/// </summary>
|
||||
private List<GridMatch> FindMatchingCells(
|
||||
byte[] gray, int imgW, int imgH,
|
||||
List<List<bool>> cells, int rows, int cols,
|
||||
|
|
@ -260,7 +236,6 @@ class GridHandler
|
|||
|
||||
int n = innerW * innerH;
|
||||
|
||||
// Pre-compute target cell pixels and stats
|
||||
double[] targetPixels = new double[n];
|
||||
double tMean = 0;
|
||||
for (int py = 0; py < innerH; py++)
|
||||
|
|
@ -277,7 +252,6 @@ class GridHandler
|
|||
tStd += (targetPixels[i] - tMean) * (targetPixels[i] - tMean);
|
||||
tStd = Math.Sqrt(tStd / n);
|
||||
|
||||
if (debug) Console.Error.WriteLine($" Match target ({targetRow},{targetCol}): {innerW}x{innerH} ({n}px), mean={tMean:F1}, std={tStd:F1}");
|
||||
if (tStd < 3.0) return [];
|
||||
|
||||
double matchThreshold = 0.70;
|
||||
|
|
@ -296,7 +270,6 @@ class GridHandler
|
|||
int cInnerH = Math.Min(innerH, imgH - cy0);
|
||||
if (cInnerW < innerW || cInnerH < innerH) continue;
|
||||
|
||||
// Compute NCC at full resolution
|
||||
double cMean = 0;
|
||||
for (int py = 0; py < innerH; py++)
|
||||
for (int px = 0; px < innerW; px++)
|
||||
|
|
@ -316,15 +289,11 @@ class GridHandler
|
|||
|
||||
double ncc = (tStd > 0 && cStd > 0) ? cross / (n * tStd * cStd) : 0;
|
||||
|
||||
if (debug && ncc > 0.5)
|
||||
Console.Error.WriteLine($" ({row},{col}): NCC={ncc:F3}");
|
||||
|
||||
if (ncc >= matchThreshold)
|
||||
matches.Add(new GridMatch { Row = row, Col = col, Similarity = Math.Round(ncc, 3) });
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) Console.Error.WriteLine($" Matches for ({targetRow},{targetCol}): {matches.Count}");
|
||||
return matches;
|
||||
}
|
||||
|
||||
|
|
@ -332,12 +301,9 @@ class GridHandler
|
|||
{
|
||||
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 = Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "..", ".."));
|
||||
var t70Path = Path.Combine(projectRoot, "assets", "empty70.png");
|
||||
var t35Path = Path.Combine(projectRoot, "assets", "empty35.png");
|
||||
// Templates are in assets/ at project root (working directory)
|
||||
var t70Path = Path.Combine("assets", "empty70.png");
|
||||
var t35Path = Path.Combine("assets", "empty35.png");
|
||||
|
||||
if (File.Exists(t70Path))
|
||||
{
|
||||
136
src/Poe2Trade.Screen/GridReader.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class GridLayout
|
||||
{
|
||||
public required Region Region { get; init; }
|
||||
public required int Cols { get; init; }
|
||||
public required int Rows { get; init; }
|
||||
}
|
||||
|
||||
public record CellCoord(int Row, int Col, int X, int Y);
|
||||
|
||||
public class ScanResult
|
||||
{
|
||||
public required GridLayout Layout { get; init; }
|
||||
public required List<CellCoord> Occupied { get; init; }
|
||||
public required List<GridItem> Items { get; init; }
|
||||
public List<GridMatch>? Matches { get; init; }
|
||||
}
|
||||
|
||||
public static class GridLayouts
|
||||
{
|
||||
public static readonly Dictionary<string, GridLayout> All = new()
|
||||
{
|
||||
["inventory"] = new GridLayout
|
||||
{
|
||||
Region = new Region(1696, 788, 840, 350),
|
||||
Cols = 12, Rows = 5
|
||||
},
|
||||
["stash12"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 169, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["stash12_folder"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["stash24"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 169, 840, 840),
|
||||
Cols = 24, Rows = 24
|
||||
},
|
||||
["stash24_folder"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 24, Rows = 24
|
||||
},
|
||||
["seller"] = new GridLayout
|
||||
{
|
||||
Region = new Region(416, 299, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["shop"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["vendor"] = new GridLayout
|
||||
{
|
||||
Region = new Region(416, 369, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly GridLayout Inventory = All["inventory"];
|
||||
public static readonly GridLayout Seller = All["seller"];
|
||||
}
|
||||
|
||||
public class GridReader
|
||||
{
|
||||
private readonly GridHandler _handler;
|
||||
|
||||
public GridReader(GridHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public Task<ScanResult> Scan(string layoutName, int threshold = 0,
|
||||
int? targetRow = null, int? targetCol = null)
|
||||
{
|
||||
if (!GridLayouts.All.TryGetValue(layoutName, out var layout))
|
||||
throw new ArgumentException($"Unknown grid layout: {layoutName}");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = _handler.Scan(layout.Region, layout.Cols, layout.Rows,
|
||||
threshold, targetRow, targetCol);
|
||||
|
||||
var occupied = new List<CellCoord>();
|
||||
for (var row = 0; row < result.Cells.Length; row++)
|
||||
for (var col = 0; col < result.Cells[row].Length; col++)
|
||||
{
|
||||
if (result.Cells[row][col])
|
||||
{
|
||||
var center = GetCellCenter(layout, row, col);
|
||||
occupied.Add(new CellCoord(row, col, center.X, center.Y));
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Grid scan {Layout}: {Occupied} occupied, {Items} items, {Ms}ms",
|
||||
layoutName, occupied.Count, result.Items.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return Task.FromResult(new ScanResult
|
||||
{
|
||||
Layout = layout,
|
||||
Occupied = occupied,
|
||||
Items = result.Items,
|
||||
Matches = result.Matches
|
||||
});
|
||||
}
|
||||
|
||||
public (int X, int Y) GetCellCenter(GridLayout layout, int row, int col)
|
||||
{
|
||||
var cellW = (double)layout.Region.Width / layout.Cols;
|
||||
var cellH = (double)layout.Region.Height / layout.Rows;
|
||||
return (
|
||||
(int)Math.Round(layout.Region.X + col * cellW + cellW / 2),
|
||||
(int)Math.Round(layout.Region.Y + row * cellH + cellH / 2)
|
||||
);
|
||||
}
|
||||
|
||||
public List<CellCoord> GetAllCells(GridLayout layout)
|
||||
{
|
||||
var cells = new List<CellCoord>();
|
||||
for (var row = 0; row < layout.Rows; row++)
|
||||
for (var col = 0; col < layout.Cols; col++)
|
||||
{
|
||||
var center = GetCellCenter(layout, row, col);
|
||||
cells.Add(new CellCoord(row, col, center.X, center.Y));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using OpenCvSharp;
|
||||
|
|
@ -9,7 +9,7 @@ static class ImagePreprocessor
|
|||
/// <summary>
|
||||
/// Pre-process an image for OCR using morphological white top-hat filtering.
|
||||
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
|
||||
/// Pipeline: grayscale → morphological top-hat → Otsu binary → upscale
|
||||
/// Pipeline: grayscale -> morphological top-hat -> Otsu binary -> upscale
|
||||
/// </summary>
|
||||
public static Bitmap PreprocessForOcr(Bitmap src, int kernelSize = 41, int upscale = 2)
|
||||
{
|
||||
|
|
@ -41,7 +41,7 @@ static class ImagePreprocessor
|
|||
/// <summary>
|
||||
/// Background-subtraction preprocessing: uses the reference frame to remove
|
||||
/// background bleed-through from the semi-transparent tooltip overlay.
|
||||
/// Pipeline: estimate dimming factor → subtract expected background → threshold → upscale
|
||||
/// Pipeline: estimate dimming factor -> subtract expected background -> threshold -> upscale
|
||||
/// Returns the upscaled binary Mat directly (caller must dispose).
|
||||
/// </summary>
|
||||
public static Mat PreprocessWithBackgroundSubMat(Bitmap tooltipCrop, Bitmap referenceCrop,
|
||||
|
|
@ -57,8 +57,6 @@ static class ImagePreprocessor
|
|||
int rows = curGray.Rows, cols = curGray.Cols;
|
||||
|
||||
// Estimate the dimming factor of the tooltip overlay.
|
||||
// For non-text pixels: current ≈ reference × dim_factor
|
||||
// Collect ratios where reference is bright enough to be meaningful
|
||||
var ratios = new List<double>();
|
||||
unsafe
|
||||
{
|
||||
|
|
@ -72,28 +70,23 @@ static class ImagePreprocessor
|
|||
{
|
||||
byte r = refPtr[y * refStep + x];
|
||||
byte c = curPtr[y * curStep + x];
|
||||
if (r > 30) // skip very dark reference pixels (no signal)
|
||||
if (r > 30)
|
||||
ratios.Add((double)c / r);
|
||||
}
|
||||
}
|
||||
|
||||
if (ratios.Count == 0)
|
||||
{
|
||||
// Fallback: use top-hat preprocessing, convert to Mat
|
||||
using var fallbackBmp = PreprocessForOcr(tooltipCrop, 41, upscale);
|
||||
return BitmapConverter.ToMat(fallbackBmp);
|
||||
}
|
||||
|
||||
// Use a low percentile of ratios as the dimming factor.
|
||||
// Text pixels have high ratios (bright on dark), overlay pixels have low ratios.
|
||||
// A low percentile captures the overlay dimming, ignoring text.
|
||||
ratios.Sort();
|
||||
int idx = Math.Clamp(ratios.Count * dimPercentile / 100, 0, ratios.Count - 1);
|
||||
double dimFactor = ratios[idx];
|
||||
// Clamp to sane range
|
||||
dimFactor = Math.Clamp(dimFactor, 0.05, 0.95);
|
||||
|
||||
// Subtract expected background: text_signal = current - reference × dimFactor
|
||||
// Subtract expected background: text_signal = current - reference * dimFactor
|
||||
using var textSignal = new Mat(rows, cols, MatType.CV_8UC1);
|
||||
unsafe
|
||||
{
|
||||
|
|
@ -116,9 +109,6 @@ static class ImagePreprocessor
|
|||
Mat result;
|
||||
if (softThreshold)
|
||||
{
|
||||
// Soft threshold: clip below textThresh, contrast-stretch, invert.
|
||||
// Produces grayscale anti-aliased text on white background,
|
||||
// matching the training data format (text2image renders).
|
||||
result = new Mat(rows, cols, MatType.CV_8UC1);
|
||||
unsafe
|
||||
{
|
||||
|
|
@ -127,7 +117,6 @@ static class ImagePreprocessor
|
|||
int srcStep = (int)textSignal.Step();
|
||||
int dstStep = (int)result.Step();
|
||||
|
||||
// Find max signal above threshold for contrast stretch
|
||||
int maxClipped = 1;
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
|
|
@ -136,26 +125,24 @@ static class ImagePreprocessor
|
|||
if (val > maxClipped) maxClipped = val;
|
||||
}
|
||||
|
||||
// Clip, stretch, invert: background → 255 (white), text → dark
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
{
|
||||
int clipped = srcPtr[y * srcStep + x] - textThresh;
|
||||
if (clipped <= 0)
|
||||
{
|
||||
dstPtr[y * dstStep + x] = 255; // background
|
||||
dstPtr[y * dstStep + x] = 255;
|
||||
}
|
||||
else
|
||||
{
|
||||
int stretched = clipped * 255 / maxClipped;
|
||||
dstPtr[y * dstStep + x] = (byte)(255 - stretched); // invert
|
||||
dstPtr[y * dstStep + x] = (byte)(255 - stretched);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hard binary threshold (original behavior)
|
||||
result = new Mat();
|
||||
Cv2.Threshold(textSignal, result, textThresh, 255, ThresholdTypes.BinaryInv);
|
||||
}
|
||||
|
|
@ -184,8 +171,6 @@ static class ImagePreprocessor
|
|||
{
|
||||
int rows = binary.Rows, cols = binary.Cols;
|
||||
|
||||
// Count dark (text) pixels per row — use < 128 threshold since
|
||||
// cubic upscaling introduces anti-aliased intermediate values
|
||||
var rowCounts = new int[rows];
|
||||
unsafe
|
||||
{
|
||||
|
|
@ -197,7 +182,6 @@ static class ImagePreprocessor
|
|||
rowCounts[y]++;
|
||||
}
|
||||
|
||||
// Group into contiguous runs with gap tolerance
|
||||
var lines = new List<(int yStart, int yEnd)>();
|
||||
int lineStart = -1, lastActive = -1;
|
||||
for (int y = 0; y < rows; y++)
|
||||
39
src/Poe2Trade.Screen/ImageUtils.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
static class ImageUtils
|
||||
{
|
||||
public static (byte[] gray, byte[] argb, int stride) BitmapToGrayAndArgb(Bitmap bmp)
|
||||
{
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] argb = new byte[data.Stride * h];
|
||||
Marshal.Copy(data.Scan0, argb, 0, argb.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)((argb[i] + argb[i + 1] + argb[i + 2]) / 3);
|
||||
}
|
||||
return (gray, argb, stride);
|
||||
}
|
||||
|
||||
public static SdImageFormat GetImageFormat(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => SdImageFormat.Jpeg,
|
||||
".bmp" => SdImageFormat.Bmp,
|
||||
_ => SdImageFormat.Png,
|
||||
};
|
||||
}
|
||||
}
|
||||
17
src/Poe2Trade.Screen/Poe2Trade.Screen.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenCvSharp4" Version="4.11.0.*" />
|
||||
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.*" />
|
||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.*" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a persistent Python subprocess for EasyOCR / PaddleOCR.
|
||||
/// Manages a persistent Python subprocess for EasyOCR.
|
||||
/// Lazy-starts on first request; reuses the process for subsequent calls.
|
||||
/// Same stdin/stdout JSON-per-line protocol as the C# daemon itself.
|
||||
/// Same stdin/stdout JSON-per-line protocol.
|
||||
/// </summary>
|
||||
class PythonOcrBridge : IDisposable
|
||||
{
|
||||
|
|
@ -26,53 +27,17 @@ class PythonOcrBridge : IDisposable
|
|||
|
||||
public PythonOcrBridge()
|
||||
{
|
||||
// Resolve paths relative to this exe
|
||||
var exeDir = AppContext.BaseDirectory;
|
||||
// exeDir = tools/OcrDaemon/bin/Release/net8.0-.../
|
||||
// Walk up 4 levels to tools/
|
||||
var toolsDir = Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", ".."));
|
||||
_daemonScript = Path.GetFullPath(Path.Combine(toolsDir, "python-ocr", "daemon.py"));
|
||||
// Resolve paths relative to working directory
|
||||
_daemonScript = Path.GetFullPath(Path.Combine("tools", "python-ocr", "daemon.py"));
|
||||
|
||||
// Use the venv Python if it exists, otherwise fall back to system python
|
||||
var venvPython = Path.GetFullPath(Path.Combine(toolsDir, "python-ocr", ".venv", "Scripts", "python.exe"));
|
||||
var venvPython = Path.GetFullPath(Path.Combine("tools", "python-ocr", ".venv", "Scripts", "python.exe"));
|
||||
_pythonExe = File.Exists(venvPython) ? venvPython : "python";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run OCR on a screen region using the specified Python engine.
|
||||
/// Captures screenshot, saves to temp file, sends to Python, returns OcrResponse.
|
||||
/// Run OCR on a bitmap via the Python EasyOCR engine (base64 PNG over pipe).
|
||||
/// </summary>
|
||||
public object HandleOcr(Request req, string engine)
|
||||
{
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"ocr_{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
bitmap.Save(tmpPath, SdImageFormat.Png);
|
||||
return OcrFromFile(tmpPath, engine);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run OCR on an already-saved image file via the Python engine.
|
||||
/// </summary>
|
||||
public OcrResponse OcrFromFile(string imagePath, string engine, OcrParams? ocrParams = null)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
var pyReq = BuildPythonRequest(engine, ocrParams);
|
||||
pyReq["imagePath"] = imagePath;
|
||||
return SendPythonRequest(pyReq);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run OCR on a bitmap via the Python engine (base64 PNG over pipe, no temp file).
|
||||
/// </summary>
|
||||
public OcrResponse OcrFromBitmap(Bitmap bitmap, string engine, OcrParams? ocrParams = null)
|
||||
public OcrResponse OcrFromBitmap(Bitmap bitmap, OcrParams? ocrParams = null)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
|
|
@ -80,14 +45,14 @@ class PythonOcrBridge : IDisposable
|
|||
bitmap.Save(ms, SdImageFormat.Png);
|
||||
var imageBase64 = Convert.ToBase64String(ms.ToArray());
|
||||
|
||||
var pyReq = BuildPythonRequest(engine, ocrParams);
|
||||
var pyReq = BuildPythonRequest(ocrParams);
|
||||
pyReq["imageBase64"] = imageBase64;
|
||||
return SendPythonRequest(pyReq);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildPythonRequest(string engine, OcrParams? ocrParams)
|
||||
private static Dictionary<string, object?> BuildPythonRequest(OcrParams? ocrParams)
|
||||
{
|
||||
var req = new Dictionary<string, object?> { ["cmd"] = "ocr", ["engine"] = engine };
|
||||
var req = new Dictionary<string, object?> { ["cmd"] = "ocr", ["engine"] = "easyocr" };
|
||||
if (ocrParams == null) return req;
|
||||
|
||||
if (ocrParams.MergeGap > 0) req["mergeGap"] = ocrParams.MergeGap;
|
||||
|
|
@ -137,7 +102,7 @@ class PythonOcrBridge : IDisposable
|
|||
if (!File.Exists(_daemonScript))
|
||||
throw new Exception($"Python OCR daemon not found at {_daemonScript}");
|
||||
|
||||
Console.Error.WriteLine($"Spawning Python OCR daemon: {_pythonExe} {_daemonScript}");
|
||||
Log.Information("Spawning Python OCR daemon: {Python} {Script}", _pythonExe, _daemonScript);
|
||||
|
||||
_proc = new Process
|
||||
{
|
||||
|
|
@ -156,7 +121,7 @@ class PythonOcrBridge : IDisposable
|
|||
_proc.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Console.Error.WriteLine($"[python-ocr] {e.Data}");
|
||||
Log.Debug("[python-ocr] {Line}", e.Data);
|
||||
};
|
||||
|
||||
_proc.Start();
|
||||
|
|
@ -171,7 +136,7 @@ class PythonOcrBridge : IDisposable
|
|||
if (ready?.Ready != true)
|
||||
throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}");
|
||||
|
||||
Console.Error.WriteLine("Python OCR daemon ready");
|
||||
Log.Information("Python OCR daemon ready");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -202,7 +167,7 @@ class PythonOcrBridge : IDisposable
|
|||
public string? Text { get; set; }
|
||||
|
||||
[JsonPropertyName("lines")]
|
||||
public List<OcrLineResult>? Lines { get; set; }
|
||||
public List<OcrLine>? Lines { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
static class ScreenCapture
|
||||
{
|
||||
|
|
@ -18,7 +19,7 @@ static class ScreenCapture
|
|||
/// Capture from screen, or load from file if specified.
|
||||
/// When file is set, loads the image and crops to region.
|
||||
/// </summary>
|
||||
public static Bitmap CaptureOrLoad(string? file, RegionRect? region)
|
||||
public static Bitmap CaptureOrLoad(string? file, Region? region)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(file))
|
||||
{
|
||||
|
|
@ -38,7 +39,7 @@ static class ScreenCapture
|
|||
return CaptureScreen(region);
|
||||
}
|
||||
|
||||
public static Bitmap CaptureScreen(RegionRect? region)
|
||||
public static Bitmap CaptureScreen(Region? region)
|
||||
{
|
||||
int x, y, w, h;
|
||||
if (region != null)
|
||||
259
src/Poe2Trade.Screen/ScreenReader.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using Poe2Trade.Core;
|
||||
using OpenCvSharp.Extensions;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class ScreenReader : IDisposable
|
||||
{
|
||||
private readonly DiffCropHandler _diffCrop = new();
|
||||
private readonly GridHandler _gridHandler = new();
|
||||
private readonly TemplateMatchHandler _templateMatch = new();
|
||||
private readonly EdgeCropHandler _edgeCrop = new();
|
||||
private readonly PythonOcrBridge _pythonBridge = new();
|
||||
private bool _initialized;
|
||||
|
||||
public GridReader Grid { get; }
|
||||
|
||||
public ScreenReader()
|
||||
{
|
||||
Grid = new GridReader(_gridHandler);
|
||||
}
|
||||
|
||||
public Task Warmup()
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
ScreenCapture.InitDpiAwareness();
|
||||
_initialized = true;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// -- Capture --
|
||||
|
||||
public Task<byte[]> CaptureScreen()
|
||||
{
|
||||
return Task.FromResult(_diffCrop.HandleCapture());
|
||||
}
|
||||
|
||||
public Task<byte[]> CaptureRegion(Region region)
|
||||
{
|
||||
return Task.FromResult(_diffCrop.HandleCapture(region));
|
||||
}
|
||||
|
||||
// -- OCR --
|
||||
|
||||
public Task<OcrResponse> Ocr(Region? region = null, string? preprocess = null)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
|
||||
|
||||
if (preprocess == "tophat")
|
||||
{
|
||||
using var processed = ImagePreprocessor.PreprocessForOcr(bitmap);
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||
}
|
||||
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindTextOnScreen(string searchText, bool fuzzy = false)
|
||||
{
|
||||
var result = await Ocr();
|
||||
var pos = FindWordInOcrResult(result, searchText, fuzzy);
|
||||
if (pos.HasValue)
|
||||
Log.Information("Found text '{Text}' at ({X},{Y})", searchText, pos.Value.X, pos.Value.Y);
|
||||
else
|
||||
Log.Information("Text '{Text}' not found on screen", searchText);
|
||||
return pos;
|
||||
}
|
||||
|
||||
public async Task<string> ReadFullScreen()
|
||||
{
|
||||
var result = await Ocr();
|
||||
return result.Text;
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindTextInRegion(Region region, string searchText)
|
||||
{
|
||||
var result = await Ocr(region);
|
||||
var pos = FindWordInOcrResult(result, searchText);
|
||||
if (pos.HasValue)
|
||||
return (region.X + pos.Value.X, region.Y + pos.Value.Y);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string> ReadRegionText(Region region)
|
||||
{
|
||||
var result = await Ocr(region);
|
||||
return result.Text;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckForText(Region region, string searchText)
|
||||
{
|
||||
var pos = await FindTextInRegion(region, searchText);
|
||||
return pos.HasValue;
|
||||
}
|
||||
|
||||
// -- Snapshot / Diff OCR --
|
||||
|
||||
public Task Snapshot()
|
||||
{
|
||||
_diffCrop.HandleSnapshot();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<DiffOcrResponse> DiffOcr(string? savePath = null, Region? region = null)
|
||||
{
|
||||
var p = new DiffOcrParams();
|
||||
var cropResult = _diffCrop.DiffCrop(p.Crop, region: region);
|
||||
if (cropResult == null)
|
||||
return Task.FromResult(new DiffOcrResponse { Text = "", Lines = [] });
|
||||
|
||||
var (cropped, refCropped, current, cropRegion) = cropResult.Value;
|
||||
using var _current = current;
|
||||
using var _cropped = cropped;
|
||||
using var _refCropped = refCropped;
|
||||
|
||||
// Save raw crop if path is provided
|
||||
if (!string.IsNullOrEmpty(savePath))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(savePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
cropped.Save(savePath, ImageUtils.GetImageFormat(savePath));
|
||||
}
|
||||
|
||||
// Preprocess with background subtraction
|
||||
var ocr = p.Ocr;
|
||||
using var processedBmp = ocr.UseBackgroundSub
|
||||
? ImagePreprocessor.PreprocessWithBackgroundSub(cropped, refCropped, ocr.DimPercentile, ocr.TextThresh, 1, ocr.SoftThreshold)
|
||||
: ImagePreprocessor.PreprocessForOcr(cropped, ocr.KernelSize, 1);
|
||||
|
||||
var ocrResult = _pythonBridge.OcrFromBitmap(processedBmp, ocr);
|
||||
|
||||
// Offset coordinates to screen space
|
||||
foreach (var line in ocrResult.Lines)
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
word.X += cropRegion.X;
|
||||
word.Y += cropRegion.Y;
|
||||
}
|
||||
|
||||
return Task.FromResult(new DiffOcrResponse
|
||||
{
|
||||
Text = ocrResult.Text,
|
||||
Lines = ocrResult.Lines,
|
||||
Region = cropRegion,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Template matching --
|
||||
|
||||
public Task<TemplateMatchResult?> TemplateMatch(string templatePath, Region? region = null)
|
||||
{
|
||||
var result = _templateMatch.Match(templatePath, region);
|
||||
if (result != null)
|
||||
Log.Information("Template match found: ({X},{Y}) confidence={Conf:F3}", result.X, result.Y, result.Confidence);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// -- Save --
|
||||
|
||||
public Task SaveScreenshot(string path)
|
||||
{
|
||||
_diffCrop.HandleScreenshot(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveRegion(Region region, string path)
|
||||
{
|
||||
_diffCrop.HandleScreenshot(path, region);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _pythonBridge.Dispose();
|
||||
|
||||
// -- OCR text matching --
|
||||
|
||||
private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy = false)
|
||||
{
|
||||
var lower = needle.ToLowerInvariant();
|
||||
const double fuzzyThreshold = 0.55;
|
||||
|
||||
if (lower.Contains(' '))
|
||||
{
|
||||
var needleNorm = Normalize(needle);
|
||||
foreach (var line in result.Lines)
|
||||
{
|
||||
if (line.Words.Count == 0) continue;
|
||||
if (line.Text.ToLowerInvariant().Contains(lower))
|
||||
return LineBoundsCenter(line);
|
||||
|
||||
if (fuzzy)
|
||||
{
|
||||
var lineNorm = Normalize(line.Text);
|
||||
var windowLen = needleNorm.Length;
|
||||
for (var i = 0; i <= lineNorm.Length - windowLen + 2; i++)
|
||||
{
|
||||
var end = Math.Min(i + windowLen + 2, lineNorm.Length);
|
||||
var window = lineNorm[i..end];
|
||||
if (BigramSimilarity(needleNorm, window) >= fuzzyThreshold)
|
||||
return LineBoundsCenter(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var needleN = Normalize(needle);
|
||||
foreach (var line in result.Lines)
|
||||
{
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
if (word.Text.ToLowerInvariant().Contains(lower))
|
||||
return (word.X + word.Width / 2, word.Y + word.Height / 2);
|
||||
|
||||
if (fuzzy && BigramSimilarity(needleN, Normalize(word.Text)) >= fuzzyThreshold)
|
||||
return (word.X + word.Width / 2, word.Y + word.Height / 2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (int X, int Y) LineBoundsCenter(OcrLine line)
|
||||
{
|
||||
var first = line.Words[0];
|
||||
var last = line.Words[^1];
|
||||
var x1 = first.X;
|
||||
var y1 = first.Y;
|
||||
var x2 = last.X + last.Width;
|
||||
var y2 = line.Words.Max(w => w.Y + w.Height);
|
||||
return ((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
}
|
||||
|
||||
private static string Normalize(string s) =>
|
||||
new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
||||
|
||||
private static double BigramSimilarity(string a, string b)
|
||||
{
|
||||
if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0;
|
||||
var bigramsA = new Dictionary<string, int>();
|
||||
for (var i = 0; i < a.Length - 1; i++)
|
||||
{
|
||||
var bg = a.Substring(i, 2);
|
||||
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
|
||||
}
|
||||
var matches = 0;
|
||||
for (var i = 0; i < b.Length - 1; i++)
|
||||
{
|
||||
var bg = b.Substring(i, 2);
|
||||
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
|
||||
{
|
||||
matches++;
|
||||
bigramsA[bg] = count - 1;
|
||||
}
|
||||
}
|
||||
return 2.0 * matches / (a.Length - 1 + b.Length - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
static class SignalProcessing
|
||||
{
|
||||
|
|
@ -27,8 +27,6 @@ static class SignalProcessing
|
|||
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])
|
||||
|
|
@ -40,8 +38,6 @@ static class SignalProcessing
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static List<(int start, int end)> FindDarkDensitySegments(double[] profile, double threshold, int minLength)
|
||||
{
|
||||
|
|
@ -85,17 +81,14 @@ static class SignalProcessing
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Find the extent of the grid in a 1D profile using local autocorrelation.
|
||||
/// </summary>
|
||||
public static (int start, int end) FindGridExtent(double[] signal, int period)
|
||||
{
|
||||
int n = signal.Length;
|
||||
int halfWin = period * 2; // window radius: 2 periods each side
|
||||
int halfWin = period * 2;
|
||||
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++)
|
||||
{
|
||||
|
|
@ -103,20 +96,17 @@ static class SignalProcessing
|
|||
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);
|
||||
|
|
@ -124,7 +114,6 @@ static class SignalProcessing
|
|||
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];
|
||||
|
|
@ -155,7 +144,6 @@ static class SignalProcessing
|
|||
}
|
||||
}
|
||||
}
|
||||
// Handle run extending to end of signal
|
||||
if (curStartPos >= 0)
|
||||
{
|
||||
int len = n - curStartPos;
|
||||
|
|
@ -168,7 +156,6 @@ static class SignalProcessing
|
|||
|
||||
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);
|
||||
|
||||
|
|
@ -1,26 +1,24 @@
|
|||
namespace OcrDaemon;
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using OpenCvSharp;
|
||||
using OpenCvSharp.Extensions;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class TemplateMatchHandler
|
||||
{
|
||||
public object HandleTemplateMatch(Request req)
|
||||
public TemplateMatchResult? Match(string templatePath, Region? region = null,
|
||||
string? file = null, double threshold = 0.7)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Path))
|
||||
return new ErrorResponse("match-template command requires 'path' (template image file)");
|
||||
if (!System.IO.File.Exists(templatePath))
|
||||
throw new FileNotFoundException($"Template file not found: {templatePath}");
|
||||
|
||||
if (!System.IO.File.Exists(req.Path))
|
||||
return new ErrorResponse($"Template file not found: {req.Path}");
|
||||
|
||||
using var screenshot = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
using var screenshot = ScreenCapture.CaptureOrLoad(file, region);
|
||||
using var screenMat = BitmapConverter.ToMat(screenshot);
|
||||
using var template = Cv2.ImRead(req.Path, ImreadModes.Color);
|
||||
using var template = Cv2.ImRead(templatePath, ImreadModes.Color);
|
||||
|
||||
if (template.Empty())
|
||||
return new ErrorResponse($"Failed to load template image: {req.Path}");
|
||||
throw new InvalidOperationException($"Failed to load template image: {templatePath}");
|
||||
|
||||
// Convert screenshot from BGRA to BGR if needed
|
||||
using var screenBgr = new Mat();
|
||||
|
|
@ -31,25 +29,21 @@ class TemplateMatchHandler
|
|||
|
||||
// Template must fit within screenshot
|
||||
if (template.Rows > screenBgr.Rows || template.Cols > screenBgr.Cols)
|
||||
return new TemplateMatchResponse { Found = false };
|
||||
return null;
|
||||
|
||||
using var result = new Mat();
|
||||
Cv2.MatchTemplate(screenBgr, template, result, TemplateMatchModes.CCoeffNormed);
|
||||
|
||||
Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out OpenCvSharp.Point maxLoc);
|
||||
|
||||
double threshold = req.Threshold > 0 ? req.Threshold / 100.0 : 0.7;
|
||||
|
||||
if (maxVal < threshold)
|
||||
return new TemplateMatchResponse { Found = false, Confidence = maxVal };
|
||||
return null;
|
||||
|
||||
// Calculate center coordinates — offset by region origin if provided
|
||||
int offsetX = req.Region?.X ?? 0;
|
||||
int offsetY = req.Region?.Y ?? 0;
|
||||
int offsetX = region?.X ?? 0;
|
||||
int offsetY = region?.Y ?? 0;
|
||||
|
||||
return new TemplateMatchResponse
|
||||
return new TemplateMatchResult
|
||||
{
|
||||
Found = true,
|
||||
X = offsetX + maxLoc.X + template.Cols / 2,
|
||||
Y = offsetY + maxLoc.Y + template.Rows / 2,
|
||||
Width = template.Cols,
|
||||
11
src/Poe2Trade.Trade/Poe2Trade.Trade.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
src/Poe2Trade.Trade/Selectors.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace Poe2Trade.Trade;
|
||||
|
||||
public static class Selectors
|
||||
{
|
||||
public const string LiveSearchButton =
|
||||
"button.livesearch-btn, button:has-text(\"Activate Live Search\")";
|
||||
|
||||
public const string ListingRow =
|
||||
".resultset .row, [class*=\"result\"]";
|
||||
|
||||
public static string ListingById(string id) => $"[data-id=\"{id}\"]";
|
||||
|
||||
public const string TravelToHideoutButton =
|
||||
"button:has-text(\"Travel to Hideout\"), button:has-text(\"Visit Hideout\"), a:has-text(\"Travel to Hideout\"), [class*=\"hideout\"]";
|
||||
|
||||
public const string WhisperButton =
|
||||
".whisper-btn, button[class*=\"whisper\"], [data-tooltip=\"Whisper\"], button:has-text(\"Whisper\")";
|
||||
|
||||
public const string ConfirmDialog =
|
||||
"[class*=\"modal\"], [class*=\"dialog\"], [class*=\"confirm\"]";
|
||||
|
||||
public const string ConfirmYesButton =
|
||||
"button:has-text(\"Yes\"), button:has-text(\"Confirm\"), button:has-text(\"OK\"), button:has-text(\"Accept\")";
|
||||
|
||||
public const string ConfirmNoButton =
|
||||
"button:has-text(\"No\"), button:has-text(\"Cancel\"), button:has-text(\"Decline\")";
|
||||
|
||||
public const string ResultsContainer =
|
||||
".resultset, [class*=\"results\"]";
|
||||
}
|
||||
296
src/Poe2Trade.Trade/TradeMonitor.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Trade;
|
||||
|
||||
public class TradeMonitor : IAsyncDisposable
|
||||
{
|
||||
private IBrowserContext? _context;
|
||||
private readonly Dictionary<string, IPage> _pages = new();
|
||||
private readonly HashSet<string> _pausedSearches = new();
|
||||
private readonly AppConfig _config;
|
||||
|
||||
private const string StealthScript = """
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
|
||||
],
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = (params) => {
|
||||
if (params.name === 'notifications')
|
||||
return Promise.resolve({ state: Notification.permission });
|
||||
return originalQuery(params);
|
||||
};
|
||||
}
|
||||
""";
|
||||
|
||||
public event Action<string, List<string>, IPage>? NewListings;
|
||||
|
||||
public TradeMonitor(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task Start(string? dashboardUrl = null)
|
||||
{
|
||||
Log.Information("Launching Playwright browser (stealth mode)...");
|
||||
|
||||
var playwright = await Playwright.CreateAsync();
|
||||
_context = await playwright.Chromium.LaunchPersistentContextAsync(
|
||||
_config.BrowserUserDataDir,
|
||||
new BrowserTypeLaunchPersistentContextOptions
|
||||
{
|
||||
Headless = false,
|
||||
ViewportSize = null,
|
||||
Args = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-features=AutomationControlled",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-infobars",
|
||||
],
|
||||
IgnoreDefaultArgs = ["--enable-automation"],
|
||||
});
|
||||
|
||||
await _context.AddInitScriptAsync(StealthScript);
|
||||
|
||||
if (dashboardUrl != null)
|
||||
{
|
||||
var pages = _context.Pages;
|
||||
if (pages.Count > 0)
|
||||
await pages[0].GotoAsync(dashboardUrl);
|
||||
else
|
||||
await (await _context.NewPageAsync()).GotoAsync(dashboardUrl);
|
||||
Log.Information("Dashboard opened: {Url}", dashboardUrl);
|
||||
}
|
||||
|
||||
Log.Information("Browser launched (stealth active)");
|
||||
}
|
||||
|
||||
public async Task AddSearch(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var searchId = ExtractSearchId(tradeUrl);
|
||||
if (_pages.ContainsKey(searchId))
|
||||
{
|
||||
Log.Information("Search already open: {SearchId}", searchId);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Adding trade search: {Url} ({SearchId})", tradeUrl, searchId);
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
_pages[searchId] = page;
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
|
||||
page.WebSocket += (_, ws) => HandleWebSocket(ws, searchId, page);
|
||||
|
||||
try
|
||||
{
|
||||
var liveBtn = page.Locator(Selectors.LiveSearchButton).First;
|
||||
await liveBtn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Live search activated: {SearchId}", searchId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Could not click Activate Live Search: {SearchId}", searchId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PauseSearch(string searchId)
|
||||
{
|
||||
_pausedSearches.Add(searchId);
|
||||
if (_pages.TryGetValue(searchId, out var page))
|
||||
{
|
||||
await page.CloseAsync();
|
||||
_pages.Remove(searchId);
|
||||
}
|
||||
Log.Information("Search paused: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
public async Task<bool> ClickTravelToHideout(IPage page, string? itemId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (itemId != null)
|
||||
{
|
||||
var row = page.Locator(Selectors.ListingById(itemId));
|
||||
if (await WaitForVisible(row, 5000))
|
||||
{
|
||||
var travelBtn = row.Locator(Selectors.TravelToHideoutButton).First;
|
||||
if (await WaitForVisible(travelBtn, 3000))
|
||||
{
|
||||
await travelBtn.ClickAsync();
|
||||
Log.Information("Clicked Travel to Hideout for item {ItemId}", itemId);
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btn = page.Locator(Selectors.TravelToHideoutButton).First;
|
||||
await btn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Clicked Travel to Hideout");
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to click Travel to Hideout");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IPage Page, List<TradeItem> Items)> OpenScrapPage(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
var items = new List<TradeItem>();
|
||||
|
||||
page.Response += async (_, response) =>
|
||||
{
|
||||
if (!response.Url.Contains("/api/trade2/fetch/")) return;
|
||||
try
|
||||
{
|
||||
var body = await response.TextAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("result", out var results) &&
|
||||
results.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in results.EnumerateArray())
|
||||
items.Add(ParseTradeItem(r));
|
||||
}
|
||||
}
|
||||
catch { /* Response may not be JSON */ }
|
||||
};
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
Log.Information("Scrap page opened: {Url} ({Count} items)", tradeUrl, items.Count);
|
||||
return (page, items);
|
||||
}
|
||||
|
||||
public string ExtractSearchId(string url)
|
||||
{
|
||||
var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
var parts = cleaned.Split('/');
|
||||
return parts.Length > 0 ? parts[^1] : url;
|
||||
}
|
||||
|
||||
public static TradeItem ParseTradeItem(JsonElement r)
|
||||
{
|
||||
var id = r.GetProperty("id").GetString() ?? "";
|
||||
int w = 1, h = 1, stashX = 0, stashY = 0;
|
||||
var account = "";
|
||||
|
||||
if (r.TryGetProperty("item", out var item))
|
||||
{
|
||||
if (item.TryGetProperty("w", out var wProp)) w = wProp.GetInt32();
|
||||
if (item.TryGetProperty("h", out var hProp)) h = hProp.GetInt32();
|
||||
}
|
||||
if (r.TryGetProperty("listing", out var listing))
|
||||
{
|
||||
if (listing.TryGetProperty("stash", out var stash))
|
||||
{
|
||||
if (stash.TryGetProperty("x", out var sx)) stashX = sx.GetInt32();
|
||||
if (stash.TryGetProperty("y", out var sy)) stashY = sy.GetInt32();
|
||||
}
|
||||
if (listing.TryGetProperty("account", out var acc) &&
|
||||
acc.TryGetProperty("name", out var accName))
|
||||
account = accName.GetString() ?? "";
|
||||
}
|
||||
return new TradeItem(id, w, h, stashX, stashY, account);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var page in _pages.Values)
|
||||
await page.CloseAsync();
|
||||
_pages.Clear();
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.CloseAsync();
|
||||
_context = null;
|
||||
}
|
||||
Log.Information("Trade monitor stopped");
|
||||
}
|
||||
|
||||
private void HandleWebSocket(IWebSocket ws, string searchId, IPage page)
|
||||
{
|
||||
if (!ws.Url.Contains("/api/trade") || !ws.Url.Contains("/live/"))
|
||||
return;
|
||||
|
||||
Log.Information("WebSocket connected for live search: {SearchId}", searchId);
|
||||
|
||||
ws.FrameReceived += (_, frame) =>
|
||||
{
|
||||
if (_pausedSearches.Contains(searchId)) return;
|
||||
try
|
||||
{
|
||||
var payload = frame.Text ?? "";
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
if (doc.RootElement.TryGetProperty("new", out var newItems) &&
|
||||
newItems.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var ids = newItems.EnumerateArray()
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => s != null)
|
||||
.ToList();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
Log.Information("New listings: {SearchId} ({Count} items)", searchId, ids.Count);
|
||||
NewListings?.Invoke(searchId, ids, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* Not all frames are JSON */ }
|
||||
};
|
||||
|
||||
ws.Close += (_, _) => Log.Warning("WebSocket closed: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDialog(IPage page)
|
||||
{
|
||||
await Helpers.Sleep(500);
|
||||
try
|
||||
{
|
||||
var confirmBtn = page.Locator(Selectors.ConfirmYesButton).First;
|
||||
if (await WaitForVisible(confirmBtn, 2000))
|
||||
{
|
||||
await confirmBtn.ClickAsync();
|
||||
Log.Information("Confirmed dialog");
|
||||
}
|
||||
}
|
||||
catch { /* No dialog */ }
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForVisible(ILocator locator, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await locator.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Visible,
|
||||
Timeout = timeoutMs
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException) { return false; }
|
||||
}
|
||||
}
|
||||
17
src/Poe2Trade.Ui/App.axaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="using:Poe2Trade.Ui.Converters"
|
||||
x:Class="Poe2Trade.Ui.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
<Application.Resources>
|
||||
<conv:LogLevelToBrushConverter x:Key="LogLevelBrush" />
|
||||
<conv:BoolToOccupiedBrushConverter x:Key="OccupiedBrush" />
|
||||
<conv:LinkModeToColorConverter x:Key="ModeBrush" />
|
||||
<conv:StatusDotBrushConverter x:Key="StatusDotBrush" />
|
||||
<conv:ActiveOpacityConverter x:Key="ActiveOpacity" />
|
||||
<conv:CellBorderConverter x:Key="CellBorderConverter" />
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
44
src/Poe2Trade.Ui/App.axaml.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
using Poe2Trade.Ui.Views;
|
||||
|
||||
namespace Poe2Trade.Ui;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var store = new ConfigStore();
|
||||
var config = AppConfig.Load();
|
||||
var bot = new BotOrchestrator(store, config);
|
||||
|
||||
var mainVm = new MainWindowViewModel(bot)
|
||||
{
|
||||
DebugVm = new DebugViewModel(bot),
|
||||
SettingsVm = new SettingsViewModel(bot)
|
||||
};
|
||||
|
||||
var window = new MainWindow { DataContext = mainVm };
|
||||
window.SetConfigStore(store);
|
||||
desktop.MainWindow = window;
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
await bot.DisposeAsync();
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
99
src/Poe2Trade.Ui/Converters/ValueConverters.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
|
||||
namespace Poe2Trade.Ui.Converters;
|
||||
|
||||
public class LogLevelToBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value?.ToString()?.ToUpperInvariant() switch
|
||||
{
|
||||
"INFO" => new SolidColorBrush(Color.Parse("#58a6ff")),
|
||||
"WARN" or "WARNING" => new SolidColorBrush(Color.Parse("#d29922")),
|
||||
"ERROR" => new SolidColorBrush(Color.Parse("#f85149")),
|
||||
"DEBUG" => new SolidColorBrush(Color.Parse("#8b949e")),
|
||||
_ => new SolidColorBrush(Color.Parse("#e6edf3")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class BoolToOccupiedBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var occupied = value is true;
|
||||
return new SolidColorBrush(occupied ? Color.Parse("#238636") : Color.Parse("#161b22"));
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class LinkModeToColorConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
|
||||
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
|
||||
_ => new SolidColorBrush(Color.Parse("#30363d")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class StatusDotBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var state = value?.ToString() ?? "Idle";
|
||||
return state switch
|
||||
{
|
||||
"Idle" => new SolidColorBrush(Color.Parse("#8b949e")),
|
||||
"Paused" => new SolidColorBrush(Color.Parse("#d29922")),
|
||||
_ => new SolidColorBrush(Color.Parse("#3fb950")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class ActiveOpacityConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is true ? 1.0 : 0.5;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class CellBorderConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is CellState cell)
|
||||
{
|
||||
return new Thickness(
|
||||
cell.BorderLeft ? 2 : 0,
|
||||
cell.BorderTop ? 2 : 0,
|
||||
cell.BorderRight ? 2 : 0,
|
||||
cell.BorderBottom ? 2 : 0);
|
||||
}
|
||||
return new Thickness(0);
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
19
src/Poe2Trade.Ui/Poe2Trade.Ui.csproj
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Bot\Poe2Trade.Bot.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
19
src/Poe2Trade.Ui/Program.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using Avalonia;
|
||||
using Poe2Trade.Core;
|
||||
|
||||
namespace Poe2Trade.Ui;
|
||||
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Logging.Setup();
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
201
src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public partial class DebugViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty] private string _findText = "";
|
||||
[ObservableProperty] private string _debugResult = "";
|
||||
[ObservableProperty] private string _selectedGridLayout = "inventory";
|
||||
[ObservableProperty] private decimal? _clickX;
|
||||
[ObservableProperty] private decimal? _clickY;
|
||||
|
||||
public string[] GridLayoutNames { get; } =
|
||||
[
|
||||
"inventory", "stash12", "stash12_folder", "stash24",
|
||||
"stash24_folder", "seller", "shop", "vendor"
|
||||
];
|
||||
|
||||
public DebugViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
}
|
||||
|
||||
private bool EnsureReady()
|
||||
{
|
||||
if (_bot.IsReady) return true;
|
||||
DebugResult = "Bot not started yet. Press Start first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TakeScreenshot()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
|
||||
Directory.CreateDirectory("debug");
|
||||
await _bot.Screen.SaveScreenshot(path);
|
||||
DebugResult = $"Screenshot saved: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Screenshot failed: {ex.Message}";
|
||||
Log.Error(ex, "Screenshot failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RunOcr()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var text = await _bot.Screen.ReadFullScreen();
|
||||
DebugResult = string.IsNullOrWhiteSpace(text) ? "(no text detected)" : text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"OCR failed: {ex.Message}";
|
||||
Log.Error(ex, "OCR failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GoHideout()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
await _bot.Game.GoToHideout();
|
||||
DebugResult = "Sent /hideout command";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Go hideout failed: {ex.Message}";
|
||||
Log.Error(ex, "Go hideout failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FindTextOnScreen()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
|
||||
DebugResult = pos.HasValue
|
||||
? $"Found '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
|
||||
: $"Text '{FindText}' not found";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Find text failed: {ex.Message}";
|
||||
Log.Error(ex, "Find text failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FindAndClick()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate(FindText);
|
||||
DebugResult = pos.HasValue
|
||||
? $"Clicked '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
|
||||
: $"Text '{FindText}' not found";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Find & click failed: {ex.Message}";
|
||||
Log.Error(ex, "Find & click failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickAt()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
var x = (int)(ClickX ?? 0);
|
||||
var y = (int)(ClickY ?? 0);
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
await _bot.Game.LeftClickAt(x, y);
|
||||
DebugResult = $"Clicked at ({x}, {y})";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Click failed: {ex.Message}";
|
||||
Log.Error(ex, "Click at failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ScanGrid()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
|
||||
DebugResult = $"Grid scan '{SelectedGridLayout}': " +
|
||||
$"{result.Layout.Cols}x{result.Layout.Rows}, " +
|
||||
$"{result.Occupied.Count} occupied, " +
|
||||
$"{result.Items.Count} items";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Grid scan failed: {ex.Message}";
|
||||
Log.Error(ex, "Grid scan failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickAnge()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||
DebugResult = pos.HasValue ? $"Clicked ANGE at ({pos.Value.X}, {pos.Value.Y})" : "ANGE not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickStash()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||
DebugResult = pos.HasValue ? $"Clicked STASH at ({pos.Value.X}, {pos.Value.Y})" : "STASH not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickSalvage()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("SALVAGE BENCH");
|
||||
DebugResult = pos.HasValue ? $"Clicked SALVAGE at ({pos.Value.X}, {pos.Value.Y})" : "SALVAGE BENCH not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
183
src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public class LogEntry
|
||||
{
|
||||
public string Time { get; init; } = "";
|
||||
public string Level { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public partial class CellState : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isOccupied;
|
||||
[ObservableProperty] private bool _borderTop;
|
||||
[ObservableProperty] private bool _borderBottom;
|
||||
[ObservableProperty] private bool _borderLeft;
|
||||
[ObservableProperty] private bool _borderRight;
|
||||
}
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _state = "Idle";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PauseButtonText))]
|
||||
private bool _isPaused;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PauseCommand))]
|
||||
private bool _isStarted;
|
||||
|
||||
[ObservableProperty] private string _newUrl = "";
|
||||
[ObservableProperty] private string _newLinkName = "";
|
||||
[ObservableProperty] private LinkMode _newLinkMode = LinkMode.Live;
|
||||
[ObservableProperty] private int _tradesCompleted;
|
||||
[ObservableProperty] private int _tradesFailed;
|
||||
[ObservableProperty] private int _activeLinksCount;
|
||||
|
||||
public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap];
|
||||
|
||||
public MainWindowViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
_isPaused = bot.IsPaused;
|
||||
|
||||
for (var i = 0; i < 60; i++)
|
||||
InventoryCells.Add(new CellState());
|
||||
|
||||
bot.StatusUpdated += () =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
State = bot.State;
|
||||
IsPaused = bot.IsPaused;
|
||||
var status = bot.GetStatus();
|
||||
TradesCompleted = status.TradesCompleted;
|
||||
TradesFailed = status.TradesFailed;
|
||||
ActiveLinksCount = status.Links.Count(l => l.Active);
|
||||
OnPropertyChanged(nameof(Links));
|
||||
UpdateInventoryGrid();
|
||||
});
|
||||
};
|
||||
|
||||
bot.LogMessage += (level, message) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Logs.Add(new LogEntry
|
||||
{
|
||||
Time = DateTime.Now.ToString("HH:mm:ss"),
|
||||
Level = level.ToUpperInvariant(),
|
||||
Message = message
|
||||
});
|
||||
if (Logs.Count > 500) Logs.RemoveAt(0);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
|
||||
public List<TradeLink> Links => _bot.Links.GetLinks();
|
||||
public ObservableCollection<LogEntry> Logs { get; } = [];
|
||||
public ObservableCollection<CellState> InventoryCells { get; } = [];
|
||||
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
|
||||
|
||||
// Sub-ViewModels for tabs
|
||||
public DebugViewModel? DebugVm { get; set; }
|
||||
public SettingsViewModel? SettingsVm { get; set; }
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStart))]
|
||||
private async Task Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _bot.Start(_bot.Config.TradeUrls);
|
||||
IsStarted = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to start bot");
|
||||
Logs.Add(new LogEntry
|
||||
{
|
||||
Time = DateTime.Now.ToString("HH:mm:ss"),
|
||||
Level = "ERROR",
|
||||
Message = $"Start failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
private bool CanStart() => !IsStarted;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanPause))]
|
||||
private void Pause()
|
||||
{
|
||||
if (_bot.IsPaused) _bot.Resume();
|
||||
else _bot.Pause();
|
||||
}
|
||||
private bool CanPause() => IsStarted;
|
||||
|
||||
[RelayCommand]
|
||||
private void AddLink()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewUrl)) return;
|
||||
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
|
||||
NewUrl = "";
|
||||
NewLinkName = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveLink(string? id)
|
||||
{
|
||||
if (id != null) _bot.RemoveLink(id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLink(string? id)
|
||||
{
|
||||
if (id == null) return;
|
||||
var link = _bot.Links.GetLink(id);
|
||||
if (link != null) _bot.ToggleLink(id, !link.Active);
|
||||
}
|
||||
|
||||
private void UpdateInventoryGrid()
|
||||
{
|
||||
if (!_bot.IsReady) return;
|
||||
var (grid, items, _) = _bot.Inventory.GetInventoryState();
|
||||
|
||||
for (var r = 0; r < 5; r++)
|
||||
for (var c = 0; c < 12; c++)
|
||||
{
|
||||
var cell = InventoryCells[r * 12 + c];
|
||||
cell.IsOccupied = grid[r, c];
|
||||
cell.BorderTop = false;
|
||||
cell.BorderBottom = false;
|
||||
cell.BorderLeft = false;
|
||||
cell.BorderRight = false;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
for (var r = item.Row; r < item.Row + item.H; r++)
|
||||
for (var c = item.Col; c < item.Col + item.W; c++)
|
||||
{
|
||||
if (r >= 5 || c >= 12) continue;
|
||||
var cell = InventoryCells[r * 12 + c];
|
||||
if (r == item.Row) cell.BorderTop = true;
|
||||
if (r == item.Row + item.H - 1) cell.BorderBottom = true;
|
||||
if (c == item.Col) cell.BorderLeft = true;
|
||||
if (c == item.Col + item.W - 1) cell.BorderRight = true;
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(InventoryFreeCells));
|
||||
}
|
||||
}
|
||||
65
src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty] private string _poe2LogPath = "";
|
||||
[ObservableProperty] private string _windowTitle = "";
|
||||
[ObservableProperty] private decimal? _travelTimeoutMs = 15000;
|
||||
[ObservableProperty] private decimal? _stashScanTimeoutMs = 10000;
|
||||
[ObservableProperty] private decimal? _waitForMoreItemsMs = 20000;
|
||||
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
|
||||
[ObservableProperty] private bool _isSaved;
|
||||
|
||||
public SettingsViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
LoadFromConfig();
|
||||
}
|
||||
|
||||
private void LoadFromConfig()
|
||||
{
|
||||
var s = _bot.Store.Settings;
|
||||
Poe2LogPath = s.Poe2LogPath;
|
||||
WindowTitle = s.Poe2WindowTitle;
|
||||
TravelTimeoutMs = s.TravelTimeoutMs;
|
||||
StashScanTimeoutMs = s.StashScanTimeoutMs;
|
||||
WaitForMoreItemsMs = s.WaitForMoreItemsMs;
|
||||
BetweenTradesDelayMs = s.BetweenTradesDelayMs;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveSettings()
|
||||
{
|
||||
_bot.Store.UpdateSettings(s =>
|
||||
{
|
||||
s.Poe2LogPath = Poe2LogPath;
|
||||
s.Poe2WindowTitle = WindowTitle;
|
||||
s.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
|
||||
s.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
|
||||
s.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
|
||||
s.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
|
||||
});
|
||||
|
||||
_bot.Config.Poe2LogPath = Poe2LogPath;
|
||||
_bot.Config.Poe2WindowTitle = WindowTitle;
|
||||
_bot.Config.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
|
||||
_bot.Config.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
|
||||
_bot.Config.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
|
||||
_bot.Config.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
|
||||
|
||||
IsSaved = true;
|
||||
}
|
||||
|
||||
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
|
||||
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
||||
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnStashScanTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnWaitForMoreItemsMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnBetweenTradesDelayMsChanged(decimal? value) => IsSaved = false;
|
||||
}
|
||||
329
src/Poe2Trade.Ui/Views/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Poe2Trade.Ui.ViewModels"
|
||||
x:Class="Poe2Trade.Ui.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="POE2 Trade Bot"
|
||||
Width="960" Height="720"
|
||||
Background="#0d1117">
|
||||
|
||||
<DockPanel Margin="12">
|
||||
|
||||
<!-- STATUS HEADER -->
|
||||
<Border DockPanel.Dock="Top" Padding="12" Margin="0,0,0,8"
|
||||
Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!-- Status dot + text -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse Width="10" Height="10"
|
||||
Fill="{Binding State, Converter={StaticResource StatusDotBrush}}" />
|
||||
<TextBlock Text="{Binding State}" FontWeight="SemiBold"
|
||||
Foreground="#e6edf3" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16"
|
||||
HorizontalAlignment="Center">
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding ActiveLinksCount}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#58a6ff"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="ACTIVE LINKS" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding TradesCompleted}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#3fb950"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="TRADES DONE" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding TradesFailed}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#f85149"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="FAILED" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Controls -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Start" Command="{Binding StartCommand}" />
|
||||
<Button Content="{Binding PauseButtonText}" Command="{Binding PauseCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- TABBED CONTENT -->
|
||||
<TabControl>
|
||||
|
||||
<!-- ========== MAIN TAB ========== -->
|
||||
<TabItem Header="Main">
|
||||
<Grid RowDefinitions="Auto,*" Margin="0,8,0,0">
|
||||
|
||||
<!-- Inventory Grid (12x5) -->
|
||||
<Border Grid.Row="0" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,0,8">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<TextBlock Text="INVENTORY" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<TextBlock Text="{Binding InventoryFreeCells, StringFormat='{}{0}/60 free'}"
|
||||
FontSize="11" Foreground="#8b949e" Margin="12,0,0,0" />
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding InventoryCells}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="12" Rows="5" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CellState">
|
||||
<Border Margin="1" CornerRadius="2" Height="22"
|
||||
Background="{Binding IsOccupied, Converter={StaticResource OccupiedBrush}}"
|
||||
BorderBrush="#3fb950" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Links + Logs split -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="350,*">
|
||||
|
||||
<!-- Left: Trade Links -->
|
||||
<Border Grid.Column="0" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,8,0">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="TRADE LINKS"
|
||||
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<!-- Add link form -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
|
||||
<TextBox Text="{Binding NewLinkName}" Watermark="Name (optional)" />
|
||||
<TextBox Text="{Binding NewUrl}" Watermark="Paste trade URL..." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ComboBox ItemsSource="{x:Static vm:MainWindowViewModel.LinkModes}"
|
||||
SelectedItem="{Binding NewLinkMode}" Width="100" />
|
||||
<Button Content="Add" Command="{Binding AddLinkCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Links list -->
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Links}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0,2" Padding="8" Background="#21262d"
|
||||
CornerRadius="4"
|
||||
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
|
||||
CommandParameter="{Binding Id}" />
|
||||
<CheckBox DockPanel.Dock="Left"
|
||||
IsChecked="{Binding Active}"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Background="{Binding Mode, Converter={StaticResource ModeBrush}}"
|
||||
CornerRadius="4" Padding="6,2">
|
||||
<TextBlock Text="{Binding Mode}"
|
||||
FontSize="10" FontWeight="Bold"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Name}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="#e6edf3" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="10"
|
||||
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Right: Logs -->
|
||||
<Border Grid.Column="1" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="ACTIVITY LOG"
|
||||
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
|
||||
Margin="0,0,0,8" />
|
||||
<ListBox ItemsSource="{Binding Logs}" x:Name="LogList"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LogEntry">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Time}" Foreground="#484f58"
|
||||
FontSize="11" FontFamily="Consolas" />
|
||||
<TextBlock Text="{Binding Message}" FontSize="11"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"
|
||||
Foreground="{Binding Level, Converter={StaticResource LogLevelBrush}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ========== DEBUG TAB ========== -->
|
||||
<TabItem Header="Debug">
|
||||
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="12" Margin="8" x:DataType="vm:DebugViewModel">
|
||||
|
||||
<!-- Row 1: Quick actions -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="QUICK ACTIONS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Screenshot" Command="{Binding TakeScreenshotCommand}" />
|
||||
<Button Content="OCR Screen" Command="{Binding RunOcrCommand}" />
|
||||
<Button Content="Go Hideout" Command="{Binding GoHideoutCommand}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
||||
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
||||
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 2: Find text -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="FIND TEXT" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding FindText}" Watermark="Text to find..."
|
||||
Width="300" />
|
||||
<Button Content="Find" Command="{Binding FindTextOnScreenCommand}" />
|
||||
<Button Content="Find & Click" Command="{Binding FindAndClickCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 3: Grid scan -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="GRID SCAN" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox ItemsSource="{Binding GridLayoutNames}"
|
||||
SelectedItem="{Binding SelectedGridLayout}" Width="160" />
|
||||
<Button Content="Scan" Command="{Binding ScanGridCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 4: Click At -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="CLICK AT POSITION" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<NumericUpDown Value="{Binding ClickX}" Watermark="X"
|
||||
Width="100" Minimum="0" Maximum="2560" />
|
||||
<NumericUpDown Value="{Binding ClickY}" Watermark="Y"
|
||||
Width="100" Minimum="0" Maximum="1440" />
|
||||
<Button Content="Click" Command="{Binding ClickAtCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Debug result output -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12" MinHeight="60">
|
||||
<StackPanel>
|
||||
<TextBlock Text="OUTPUT" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" Margin="0,0,0,6" />
|
||||
<TextBlock Text="{Binding DebugResult}" FontFamily="Consolas"
|
||||
FontSize="11" Foreground="#e6edf3" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ========== SETTINGS TAB ========== -->
|
||||
<TabItem Header="Settings">
|
||||
<ScrollViewer DataContext="{Binding SettingsVm}" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="12" Margin="8" MaxWidth="600"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="GENERAL SETTINGS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="POE2 Client.txt Path" FontSize="11" Foreground="#8b949e" />
|
||||
<TextBox Text="{Binding Poe2LogPath}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Window Title" FontSize="11" Foreground="#8b949e" />
|
||||
<TextBox Text="{Binding WindowTitle}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4" Margin="0,0,6,8">
|
||||
<TextBlock Text="Travel Timeout (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding TravelTimeoutMs}" Minimum="1000"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="6,0,0,8">
|
||||
<TextBlock Text="Stash Scan Timeout (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding StashScanTimeoutMs}" Minimum="1000"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,0,6,0">
|
||||
<TextBlock Text="Wait for More Items (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding WaitForMoreItemsMs}" Minimum="1000"
|
||||
Maximum="120000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="6,0,0,0">
|
||||
<TextBlock Text="Delay Between Trades (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding BetweenTradesDelayMs}" Minimum="0"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
|
||||
<Button Content="Save Settings" Command="{Binding SaveSettingsCommand}" />
|
||||
<TextBlock Text="Saved!" Foreground="#3fb950" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsSaved}" FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
69
src/Poe2Trade.Ui/Views/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
|
||||
namespace Poe2Trade.Ui.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private ConfigStore? _store;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void SetConfigStore(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
var s = store.Settings;
|
||||
if (s.WindowWidth.HasValue && s.WindowHeight.HasValue)
|
||||
{
|
||||
Width = s.WindowWidth.Value;
|
||||
Height = s.WindowHeight.Value;
|
||||
}
|
||||
if (s.WindowX.HasValue && s.WindowY.HasValue)
|
||||
{
|
||||
Position = new PixelPoint((int)s.WindowX.Value, (int)s.WindowY.Value);
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
vm.Logs.CollectionChanged += (_, args) =>
|
||||
{
|
||||
if (args.Action == NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
var logList = this.FindControl<ListBox>("LogList");
|
||||
if (logList != null && vm.Logs.Count > 0)
|
||||
logList.ScrollIntoView(vm.Logs[^1]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
_store.UpdateSettings(s =>
|
||||
{
|
||||
s.WindowX = Position.X;
|
||||
s.WindowY = Position.Y;
|
||||
s.WindowWidth = Width;
|
||||
s.WindowHeight = Height;
|
||||
});
|
||||
}
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
10
src/Poe2Trade.Ui/app.manifest
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?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="Poe2Trade"/>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
|
|
@ -1,623 +0,0 @@
|
|||
namespace OcrDaemon;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Tesseract;
|
||||
|
||||
static class Daemon
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static int Run()
|
||||
{
|
||||
ScreenCapture.InitDpiAwareness();
|
||||
|
||||
// Pre-create the Tesseract OCR engine (reused across all requests)
|
||||
var tessdataPath = Path.Combine(AppContext.BaseDirectory, "tessdata");
|
||||
var tessLang = File.Exists(Path.Combine(tessdataPath, "poe2.traineddata")) ? "poe2" : "eng";
|
||||
TesseractEngine tessEngine;
|
||||
try
|
||||
{
|
||||
tessEngine = new TesseractEngine(tessdataPath, tessLang, EngineMode.LstmOnly);
|
||||
tessEngine.DefaultPageSegMode = PageSegMode.SingleBlock;
|
||||
tessEngine.SetVariable("preserve_interword_spaces", "1");
|
||||
var userWordsPath = Path.Combine(tessdataPath, $"{tessLang}.user-words");
|
||||
var userPatternsPath = Path.Combine(tessdataPath, $"{tessLang}.user-patterns");
|
||||
if (File.Exists(userWordsPath))
|
||||
{
|
||||
tessEngine.SetVariable("user_words_file", userWordsPath);
|
||||
var lineCount = File.ReadAllLines(userWordsPath).Length;
|
||||
Console.Error.WriteLine($"Loaded user-words: {lineCount} words from {userWordsPath}");
|
||||
}
|
||||
if (File.Exists(userPatternsPath))
|
||||
{
|
||||
tessEngine.SetVariable("user_patterns_file", userPatternsPath);
|
||||
var lineCount = File.ReadAllLines(userPatternsPath).Length;
|
||||
Console.Error.WriteLine($"Loaded user-patterns: {lineCount} patterns from {userPatternsPath}");
|
||||
}
|
||||
Console.Error.WriteLine($"Tesseract engine loaded with language: {tessLang}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteResponse(new ErrorResponse($"Failed to create Tesseract engine: {ex.Message}. Ensure tessdata/eng.traineddata exists."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Signal ready
|
||||
WriteResponse(new ReadyResponse());
|
||||
|
||||
var ocrHandler = new OcrHandler(tessEngine);
|
||||
var gridHandler = new GridHandler();
|
||||
var detectGridHandler = new DetectGridHandler();
|
||||
var templateMatchHandler = new TemplateMatchHandler();
|
||||
var edgeCropHandler = new EdgeCropHandler();
|
||||
var pythonBridge = new PythonOcrBridge();
|
||||
|
||||
// Main loop: read one JSON line, handle, write one JSON line
|
||||
string? line;
|
||||
while ((line = Console.In.ReadLine()) != null)
|
||||
{
|
||||
line = line.Trim();
|
||||
if (line.Length == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var request = JsonSerializer.Deserialize<Request>(line, JsonOptions);
|
||||
if (request == null)
|
||||
{
|
||||
WriteResponse(new ErrorResponse("Failed to parse request"));
|
||||
continue;
|
||||
}
|
||||
|
||||
object response = request.Cmd?.ToLowerInvariant() switch
|
||||
{
|
||||
"ocr" => HandleOcrPipeline(ocrHandler, pythonBridge, request),
|
||||
"screenshot" => ocrHandler.HandleScreenshot(request),
|
||||
"capture" => ocrHandler.HandleCapture(request),
|
||||
"snapshot" => ocrHandler.HandleSnapshot(request),
|
||||
"diff-ocr" => HandleDiffOcrPipeline(ocrHandler, pythonBridge, request),
|
||||
"edge-ocr" => HandleEdgeOcrPipeline(ocrHandler, edgeCropHandler, pythonBridge, request),
|
||||
"test" => ocrHandler.HandleTest(request),
|
||||
"tune" => ocrHandler.HandleTune(request),
|
||||
"crop-test" => HandleCropTest(ocrHandler, edgeCropHandler, request),
|
||||
"crop-tune" => HandleCropTune(ocrHandler, request),
|
||||
"grid" => gridHandler.HandleGrid(request),
|
||||
"detect-grid" => detectGridHandler.HandleDetectGrid(request),
|
||||
"match-template" => templateMatchHandler.HandleTemplateMatch(request),
|
||||
_ => new ErrorResponse($"Unknown command: {request.Cmd}"),
|
||||
};
|
||||
WriteResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteResponse(new ErrorResponse(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
pythonBridge.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified OCR pipeline for full/region captures.
|
||||
/// Capture → optional preprocess → route to engine (tesseract / easyocr / paddleocr).
|
||||
/// </summary>
|
||||
private static object HandleOcrPipeline(OcrHandler ocrHandler, PythonOcrBridge pythonBridge, Request request)
|
||||
{
|
||||
var engine = request.Engine ?? "tesseract";
|
||||
var preprocess = request.Preprocess ?? "none";
|
||||
|
||||
var kernelSize = request.Params?.Ocr.KernelSize ?? 41;
|
||||
|
||||
// No preprocess + tesseract = original fast path
|
||||
if (engine == "tesseract" && preprocess == "none")
|
||||
return ocrHandler.HandleOcr(request);
|
||||
|
||||
// Capture
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(request.File, request.Region);
|
||||
|
||||
// Preprocess
|
||||
Bitmap processed;
|
||||
if (preprocess == "tophat")
|
||||
{
|
||||
processed = ImagePreprocessor.PreprocessForOcr(bitmap, kernelSize: kernelSize);
|
||||
}
|
||||
else if (preprocess == "bgsub")
|
||||
{
|
||||
return new ErrorResponse("bgsub preprocess requires a reference frame; use diff-ocr instead.");
|
||||
}
|
||||
else // "none"
|
||||
{
|
||||
processed = (Bitmap)bitmap.Clone();
|
||||
}
|
||||
using var _processed = processed;
|
||||
|
||||
// Route to engine
|
||||
if (engine == "tesseract")
|
||||
{
|
||||
var region = request.Region != null
|
||||
? new RegionRect { X = request.Region.X, Y = request.Region.Y, Width = request.Region.Width, Height = request.Region.Height }
|
||||
: new RegionRect { X = 0, Y = 0, Width = processed.Width, Height = processed.Height };
|
||||
return ocrHandler.RunTesseractOnBitmap(processed, region);
|
||||
}
|
||||
else // easyocr, paddleocr
|
||||
{
|
||||
return pythonBridge.OcrFromBitmap(processed, engine);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified diff-OCR pipeline for tooltip detection.
|
||||
/// DiffCrop → preprocess (default=bgsub) → route to engine.
|
||||
/// </summary>
|
||||
private static object HandleDiffOcrPipeline(OcrHandler ocrHandler, PythonOcrBridge pythonBridge, Request request)
|
||||
{
|
||||
var engine = request.Engine ?? "tesseract";
|
||||
var isPythonEngine = engine is "easyocr" or "paddleocr";
|
||||
var p = request.Params ?? new DiffOcrParams();
|
||||
var cropParams = p.Crop;
|
||||
var ocrParams = p.Ocr;
|
||||
if (request.Threshold > 0) cropParams.DiffThresh = request.Threshold;
|
||||
|
||||
// Determine preprocess mode: explicit request.Preprocess > params.UseBackgroundSub > default "bgsub"
|
||||
string preprocess;
|
||||
if (request.Preprocess != null)
|
||||
preprocess = request.Preprocess;
|
||||
else if (request.Params != null)
|
||||
preprocess = ocrParams.UseBackgroundSub ? "bgsub" : "tophat";
|
||||
else
|
||||
preprocess = "bgsub";
|
||||
|
||||
// No engine override + no preprocess override + no params = original Tesseract path
|
||||
if (engine == "tesseract" && request.Preprocess == null && request.Params == null)
|
||||
return ocrHandler.HandleDiffOcr(request);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var cropResult = ocrHandler.DiffCrop(request, cropParams);
|
||||
if (cropResult == null)
|
||||
return new OcrResponse { Text = "", Lines = [] };
|
||||
|
||||
var (cropped, refCropped, current, region) = cropResult.Value;
|
||||
using var _current = current;
|
||||
|
||||
// Preprocess — only sees ocrParams
|
||||
Bitmap processed;
|
||||
if (preprocess == "bgsub")
|
||||
{
|
||||
int upscale = isPythonEngine ? 1 : ocrParams.Upscale;
|
||||
processed = ImagePreprocessor.PreprocessWithBackgroundSub(
|
||||
cropped, refCropped, dimPercentile: ocrParams.DimPercentile, textThresh: ocrParams.TextThresh,
|
||||
upscale: upscale, softThreshold: ocrParams.SoftThreshold);
|
||||
}
|
||||
else if (preprocess == "tophat")
|
||||
{
|
||||
processed = ImagePreprocessor.PreprocessForOcr(cropped, kernelSize: ocrParams.KernelSize);
|
||||
}
|
||||
else // "none"
|
||||
{
|
||||
processed = (Bitmap)cropped.Clone();
|
||||
}
|
||||
cropped.Dispose();
|
||||
refCropped.Dispose();
|
||||
|
||||
var diffMs = sw.ElapsedMilliseconds;
|
||||
using var _processed = processed;
|
||||
|
||||
// Save debug images if path provided
|
||||
if (!string.IsNullOrEmpty(request.Path))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(request.Path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
// Save preprocessed crop
|
||||
processed.Save(request.Path, ImageUtils.GetImageFormat(request.Path));
|
||||
|
||||
var ext = Path.GetExtension(request.Path);
|
||||
var fullPath = Path.ChangeExtension(request.Path, ".full" + ext);
|
||||
current.Save(fullPath, ImageUtils.GetImageFormat(fullPath));
|
||||
}
|
||||
|
||||
// Route to engine
|
||||
sw.Restart();
|
||||
if (engine == "tesseract")
|
||||
{
|
||||
var result = ocrHandler.RunTesseractOnBitmap(processed, region);
|
||||
var ocrMs = sw.ElapsedMilliseconds;
|
||||
Console.Error.WriteLine($" diff-ocr-pipeline: engine={engine} preprocess={preprocess} diff={diffMs}ms ocr={ocrMs}ms crop={region.Width}x{region.Height}");
|
||||
return result;
|
||||
}
|
||||
else // easyocr, paddleocr
|
||||
{
|
||||
var ocrResult = pythonBridge.OcrFromBitmap(processed, engine, ocrParams);
|
||||
var ocrMs = sw.ElapsedMilliseconds;
|
||||
Console.Error.WriteLine($" diff-ocr-pipeline: engine={engine} preprocess={preprocess} diff={diffMs}ms ocr={ocrMs}ms crop={region.Width}x{region.Height}");
|
||||
|
||||
// Offset word coordinates to screen space
|
||||
foreach (var line in ocrResult.Lines)
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
word.X += region.X;
|
||||
word.Y += region.Y;
|
||||
}
|
||||
|
||||
return new DiffOcrResponse
|
||||
{
|
||||
Text = ocrResult.Text,
|
||||
Lines = ocrResult.Lines,
|
||||
Region = region,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-based tooltip detection pipeline.
|
||||
/// EdgeCrop → preprocess (tophat only; bgsub falls back to tophat) → route to engine.
|
||||
/// </summary>
|
||||
private static object HandleEdgeOcrPipeline(OcrHandler ocrHandler, EdgeCropHandler edgeCropHandler, PythonOcrBridge pythonBridge, Request request)
|
||||
{
|
||||
var engine = request.Engine ?? "tesseract";
|
||||
var isPythonEngine = engine is "easyocr" or "paddleocr";
|
||||
var ep = request.EdgeParams ?? new EdgeOcrParams();
|
||||
var cropParams = ep.Crop;
|
||||
var ocrParams = ep.Ocr;
|
||||
|
||||
// Edge method only supports tophat (no reference frame for bgsub)
|
||||
string preprocess = request.Preprocess ?? "tophat";
|
||||
if (preprocess == "bgsub") preprocess = "tophat";
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var cropResult = edgeCropHandler.EdgeCrop(request, cropParams);
|
||||
if (cropResult == null)
|
||||
return new OcrResponse { Text = "", Lines = [] };
|
||||
|
||||
var (cropped, fullCapture, region) = cropResult.Value;
|
||||
using var _fullCapture = fullCapture;
|
||||
|
||||
// Preprocess
|
||||
Bitmap processed;
|
||||
if (preprocess == "tophat")
|
||||
{
|
||||
processed = ImagePreprocessor.PreprocessForOcr(cropped, kernelSize: ocrParams.KernelSize, upscale: ocrParams.Upscale);
|
||||
}
|
||||
else // "none"
|
||||
{
|
||||
processed = (Bitmap)cropped.Clone();
|
||||
}
|
||||
cropped.Dispose();
|
||||
|
||||
var cropMs = sw.ElapsedMilliseconds;
|
||||
using var _processed = processed;
|
||||
|
||||
// Save debug images if path provided
|
||||
if (!string.IsNullOrEmpty(request.Path))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(request.Path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
processed.Save(request.Path, ImageUtils.GetImageFormat(request.Path));
|
||||
|
||||
var ext = Path.GetExtension(request.Path);
|
||||
var fullPath = Path.ChangeExtension(request.Path, ".full" + ext);
|
||||
fullCapture.Save(fullPath, ImageUtils.GetImageFormat(fullPath));
|
||||
}
|
||||
|
||||
// Route to engine
|
||||
sw.Restart();
|
||||
if (engine == "tesseract")
|
||||
{
|
||||
var result = ocrHandler.RunTesseractOnBitmap(processed, region, pad: cropParams.OcrPad, upscale: ocrParams.Upscale);
|
||||
var ocrMs = sw.ElapsedMilliseconds;
|
||||
Console.Error.WriteLine($" edge-ocr-pipeline: engine={engine} preprocess={preprocess} crop={cropMs}ms ocr={ocrMs}ms region={region.Width}x{region.Height}");
|
||||
return result;
|
||||
}
|
||||
else // easyocr, paddleocr
|
||||
{
|
||||
var ocrResult = pythonBridge.OcrFromBitmap(processed, engine, ocrParams);
|
||||
var ocrMs = sw.ElapsedMilliseconds;
|
||||
Console.Error.WriteLine($" edge-ocr-pipeline: engine={engine} preprocess={preprocess} crop={cropMs}ms ocr={ocrMs}ms region={region.Width}x{region.Height}");
|
||||
|
||||
foreach (var line in ocrResult.Lines)
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
word.X += region.X;
|
||||
word.Y += region.Y;
|
||||
}
|
||||
|
||||
return new DiffOcrResponse
|
||||
{
|
||||
Text = ocrResult.Text,
|
||||
Lines = ocrResult.Lines,
|
||||
Region = region,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate-descent sweep over DiffCropParams to maximise avgIoU on crop.json ground truth.
|
||||
/// </summary>
|
||||
private static object HandleCropTune(OcrHandler ocrHandler, Request request)
|
||||
{
|
||||
var tessdataDir = Path.Combine(AppContext.BaseDirectory, "tessdata");
|
||||
var casesPath = Path.Combine(tessdataDir, "crop.json");
|
||||
if (!File.Exists(casesPath))
|
||||
return new ErrorResponse($"crop.json not found at {casesPath}");
|
||||
|
||||
var json = File.ReadAllText(casesPath);
|
||||
var cases = JsonSerializer.Deserialize<List<CropTestCase>>(json, JsonOptions);
|
||||
if (cases == null || cases.Count == 0)
|
||||
return new ErrorResponse("No test cases in crop.json");
|
||||
|
||||
// Preload valid test cases
|
||||
var validCases = new List<(CropTestCase tc, string imagePath, string snapshotPath)>();
|
||||
foreach (var tc in cases)
|
||||
{
|
||||
var imagePath = Path.Combine(tessdataDir, tc.Image);
|
||||
var snapshotPath = Path.Combine(tessdataDir, tc.SnapshotImage);
|
||||
if (File.Exists(imagePath) && File.Exists(snapshotPath))
|
||||
validCases.Add((tc, imagePath, snapshotPath));
|
||||
}
|
||||
if (validCases.Count == 0)
|
||||
return new ErrorResponse("No valid test cases found");
|
||||
|
||||
// Score function: compute avgIoU for a set of crop params
|
||||
double ScoreCropParams(DiffCropParams cp)
|
||||
{
|
||||
double totalIoU = 0;
|
||||
foreach (var (tc, imagePath, snapshotPath) in validCases)
|
||||
{
|
||||
ocrHandler.HandleSnapshot(new Request { File = snapshotPath });
|
||||
var cropResult = ocrHandler.DiffCrop(new Request { File = imagePath }, cp);
|
||||
if (cropResult == null) continue;
|
||||
|
||||
var (cropped, refCropped, current, region) = cropResult.Value;
|
||||
cropped.Dispose(); refCropped.Dispose(); current.Dispose();
|
||||
|
||||
int ax1 = region.X, ay1 = region.Y;
|
||||
int ax2 = region.X + region.Width, ay2 = region.Y + region.Height;
|
||||
int ex1 = tc.TopLeft.X, ey1 = tc.TopLeft.Y, ex2 = tc.BottomRight.X, ey2 = tc.BottomRight.Y;
|
||||
|
||||
int ix1 = Math.Max(ax1, ex1), iy1 = Math.Max(ay1, ey1);
|
||||
int ix2 = Math.Min(ax2, ex2), iy2 = Math.Min(ay2, ey2);
|
||||
int iw = Math.Max(0, ix2 - ix1), ih = Math.Max(0, iy2 - iy1);
|
||||
double intersection = (double)iw * ih;
|
||||
double expW = ex2 - ex1, expH = ey2 - ey1;
|
||||
double union = (double)region.Width * region.Height + expW * expH - intersection;
|
||||
totalIoU += union > 0 ? intersection / union : 0;
|
||||
}
|
||||
return totalIoU / validCases.Count;
|
||||
}
|
||||
|
||||
DiffCropParams CloneCrop(DiffCropParams p) => new()
|
||||
{
|
||||
DiffThresh = p.DiffThresh, RowThreshDiv = p.RowThreshDiv,
|
||||
ColThreshDiv = p.ColThreshDiv, MaxGap = p.MaxGap,
|
||||
TrimCutoff = p.TrimCutoff, OcrPad = p.OcrPad,
|
||||
};
|
||||
|
||||
// Start from provided params or defaults
|
||||
var best = request.Params?.Crop ?? new DiffCropParams();
|
||||
double bestScore = ScoreCropParams(best);
|
||||
int totalEvals = 1;
|
||||
Console.Error.WriteLine($" crop-tune: baseline avgIoU={bestScore:F4} {best}");
|
||||
|
||||
var intSweeps = new (string Name, int[] Values, Action<DiffCropParams, int> Set)[]
|
||||
{
|
||||
("diffThresh", [5, 10, 15, 20, 25, 30, 40], (c, v) => c.DiffThresh = v),
|
||||
("rowThreshDiv", [20, 30, 40, 50, 60, 80, 100], (c, v) => c.RowThreshDiv = v),
|
||||
("colThreshDiv", [5, 8, 10, 12, 15, 20], (c, v) => c.ColThreshDiv = v),
|
||||
("maxGap", [5, 10, 15, 20, 25, 30], (c, v) => c.MaxGap = v),
|
||||
};
|
||||
double[] trimValues = [0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5];
|
||||
|
||||
const int maxRounds = 3;
|
||||
for (int round = 0; round < maxRounds; round++)
|
||||
{
|
||||
bool improved = false;
|
||||
Console.Error.WriteLine($"--- Round {round + 1} ---");
|
||||
|
||||
foreach (var (name, values, set) in intSweeps)
|
||||
{
|
||||
Console.Error.Write($" {name}: ");
|
||||
int bestVal = 0;
|
||||
double bestValScore = -1;
|
||||
|
||||
foreach (int v in values)
|
||||
{
|
||||
var trial = CloneCrop(best);
|
||||
set(trial, v);
|
||||
double score = ScoreCropParams(trial);
|
||||
totalEvals++;
|
||||
Console.Error.Write($"{v}={score:F4} ");
|
||||
if (score > bestValScore) { bestValScore = score; bestVal = v; }
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
|
||||
if (bestValScore > bestScore)
|
||||
{
|
||||
set(best, bestVal);
|
||||
bestScore = bestValScore;
|
||||
improved = true;
|
||||
Console.Error.WriteLine($" -> {name}={bestVal} avgIoU={bestScore:F4}");
|
||||
}
|
||||
}
|
||||
|
||||
// trimCutoff sweep
|
||||
{
|
||||
Console.Error.Write($" trimCutoff: ");
|
||||
double bestTrim = best.TrimCutoff;
|
||||
double bestTrimScore = bestScore;
|
||||
|
||||
foreach (double v in trimValues)
|
||||
{
|
||||
var trial = CloneCrop(best);
|
||||
trial.TrimCutoff = v;
|
||||
double score = ScoreCropParams(trial);
|
||||
totalEvals++;
|
||||
Console.Error.Write($"{v:F2}={score:F4} ");
|
||||
if (score > bestTrimScore) { bestTrimScore = score; bestTrim = v; }
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
|
||||
if (bestTrimScore > bestScore)
|
||||
{
|
||||
best.TrimCutoff = bestTrim;
|
||||
bestScore = bestTrimScore;
|
||||
improved = true;
|
||||
Console.Error.WriteLine($" -> trimCutoff={bestTrim:F2} avgIoU={bestScore:F4}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($" End of round {round + 1}: avgIoU={bestScore:F4} {best}");
|
||||
if (!improved) break;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"\n crop-tune: BEST avgIoU={bestScore:F4} {best} evals={totalEvals}");
|
||||
|
||||
return new CropTuneResponse
|
||||
{
|
||||
BestAvgIoU = bestScore,
|
||||
BestParams = best,
|
||||
Iterations = totalEvals,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crop accuracy test: runs diff and/or edge crop on test cases from crop.json,
|
||||
/// computes IoU and per-edge deltas vs ground truth.
|
||||
/// </summary>
|
||||
private static object HandleCropTest(OcrHandler ocrHandler, EdgeCropHandler edgeCropHandler, Request request)
|
||||
{
|
||||
var tessdataDir = Path.Combine(AppContext.BaseDirectory, "tessdata");
|
||||
var casesPath = Path.Combine(tessdataDir, "crop.json");
|
||||
if (!File.Exists(casesPath))
|
||||
return new ErrorResponse($"crop.json not found at {casesPath}");
|
||||
|
||||
var json = File.ReadAllText(casesPath);
|
||||
var cases = JsonSerializer.Deserialize<List<CropTestCase>>(json, JsonOptions);
|
||||
if (cases == null || cases.Count == 0)
|
||||
return new ErrorResponse("No test cases in crop.json");
|
||||
|
||||
var method = request.Engine ?? "diff"; // reuse engine field: "diff", "edge", or "both"
|
||||
var diffParams = request.Params?.Crop ?? new DiffCropParams();
|
||||
var edgeParams = request.EdgeParams?.Crop ?? new EdgeCropParams();
|
||||
|
||||
var results = new List<CropTestResult>();
|
||||
|
||||
foreach (var tc in cases)
|
||||
{
|
||||
var imagePath = Path.Combine(tessdataDir, tc.Image);
|
||||
var snapshotPath = Path.Combine(tessdataDir, tc.SnapshotImage);
|
||||
|
||||
if (!File.Exists(imagePath) || !File.Exists(snapshotPath))
|
||||
{
|
||||
Console.Error.WriteLine($" crop-test: SKIP {tc.Id} — missing files");
|
||||
results.Add(new CropTestResult { Id = tc.Id, IoU = 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expected region
|
||||
int expX = tc.TopLeft.X;
|
||||
int expY = tc.TopLeft.Y;
|
||||
int expW = tc.BottomRight.X - tc.TopLeft.X;
|
||||
int expH = tc.BottomRight.Y - tc.TopLeft.Y;
|
||||
var expected = new RegionRect { X = expX, Y = expY, Width = expW, Height = expH };
|
||||
|
||||
RegionRect? actual = null;
|
||||
|
||||
if (method is "diff" or "both")
|
||||
{
|
||||
// Load snapshot as reference
|
||||
ocrHandler.HandleSnapshot(new Request { File = snapshotPath });
|
||||
var cropResult = ocrHandler.DiffCrop(new Request { File = imagePath }, diffParams);
|
||||
if (cropResult != null)
|
||||
{
|
||||
var (cropped, refCropped, current, region) = cropResult.Value;
|
||||
actual = region;
|
||||
cropped.Dispose();
|
||||
refCropped.Dispose();
|
||||
current.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (method == "edge")
|
||||
{
|
||||
// Default cursor to center of ground-truth bbox if not specified
|
||||
int cx = tc.CursorX ?? (tc.TopLeft.X + tc.BottomRight.X) / 2;
|
||||
int cy = tc.CursorY ?? (tc.TopLeft.Y + tc.BottomRight.Y) / 2;
|
||||
var cropResult = edgeCropHandler.EdgeCrop(
|
||||
new Request { File = imagePath, CursorX = cx, CursorY = cy },
|
||||
edgeParams);
|
||||
if (cropResult != null)
|
||||
{
|
||||
var (cropped, fullCapture, region) = cropResult.Value;
|
||||
actual = region;
|
||||
cropped.Dispose();
|
||||
fullCapture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Compute IoU and deltas
|
||||
double iou = 0;
|
||||
int dTop = 0, dLeft = 0, dRight = 0, dBottom = 0;
|
||||
if (actual != null)
|
||||
{
|
||||
int ax1 = actual.X, ay1 = actual.Y;
|
||||
int ax2 = actual.X + actual.Width, ay2 = actual.Y + actual.Height;
|
||||
int ex1 = expX, ey1 = expY, ex2 = tc.BottomRight.X, ey2 = tc.BottomRight.Y;
|
||||
|
||||
int ix1 = Math.Max(ax1, ex1), iy1 = Math.Max(ay1, ey1);
|
||||
int ix2 = Math.Min(ax2, ex2), iy2 = Math.Min(ay2, ey2);
|
||||
int iw = Math.Max(0, ix2 - ix1), ih = Math.Max(0, iy2 - iy1);
|
||||
double intersection = (double)iw * ih;
|
||||
double union = (double)actual.Width * actual.Height + (double)expW * expH - intersection;
|
||||
iou = union > 0 ? intersection / union : 0;
|
||||
|
||||
dTop = ay1 - ey1; // positive = crop starts too low
|
||||
dLeft = ax1 - ex1; // positive = crop starts too far right
|
||||
dRight = ax2 - ex2; // positive = crop ends too far right
|
||||
dBottom = ay2 - ey2; // positive = crop ends too low
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($" crop-test #{tc.Id}: IoU={iou:F3} expected=({expX},{expY})+{expW}x{expH} actual={FormatRegion(actual)} delta T={dTop:+0;-#} L={dLeft:+0;-#} R={dRight:+0;-#} B={dBottom:+0;-#}");
|
||||
|
||||
results.Add(new CropTestResult
|
||||
{
|
||||
Id = tc.Id,
|
||||
IoU = iou,
|
||||
Expected = expected,
|
||||
Actual = actual,
|
||||
DeltaTop = dTop,
|
||||
DeltaLeft = dLeft,
|
||||
DeltaRight = dRight,
|
||||
DeltaBottom = dBottom,
|
||||
});
|
||||
}
|
||||
|
||||
double avgIoU = results.Count > 0 ? results.Average(r => r.IoU) : 0;
|
||||
Console.Error.WriteLine($" crop-test: method={method} avgIoU={avgIoU:F3} ({results.Count} cases)");
|
||||
|
||||
return new CropTestResponse
|
||||
{
|
||||
Method = method,
|
||||
AvgIoU = avgIoU,
|
||||
Results = results,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatRegion(RegionRect? r) =>
|
||||
r != null ? $"({r.X},{r.Y})+{r.Width}x{r.Height}" : "null";
|
||||
|
||||
private static void WriteResponse(object response)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
Console.Out.WriteLine(json);
|
||||
Console.Out.Flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
namespace OcrDaemon;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Tesseract;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
static class ImageUtils
|
||||
{
|
||||
public static Pix BitmapToPix(Bitmap bitmap)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, SdImageFormat.Png);
|
||||
return Pix.LoadFromMemory(ms.ToArray());
|
||||
}
|
||||
|
||||
public static List<OcrLineResult> ExtractLinesFromPage(Page page, int offsetX, int offsetY)
|
||||
{
|
||||
var lines = new List<OcrLineResult>();
|
||||
using var iter = page.GetIterator();
|
||||
if (iter == null) return lines;
|
||||
|
||||
iter.Begin();
|
||||
|
||||
do
|
||||
{
|
||||
var words = new List<OcrWordResult>();
|
||||
do
|
||||
{
|
||||
var wordText = iter.GetText(PageIteratorLevel.Word);
|
||||
if (string.IsNullOrWhiteSpace(wordText)) continue;
|
||||
|
||||
float conf = iter.GetConfidence(PageIteratorLevel.Word);
|
||||
if (conf < 50) continue; // reject low-confidence garbage from background bleed
|
||||
|
||||
if (iter.TryGetBoundingBox(PageIteratorLevel.Word, out var bounds))
|
||||
{
|
||||
words.Add(new OcrWordResult
|
||||
{
|
||||
Text = wordText.Trim(),
|
||||
X = bounds.X1 + offsetX,
|
||||
Y = bounds.Y1 + offsetY,
|
||||
Width = bounds.Width,
|
||||
Height = bounds.Height,
|
||||
});
|
||||
}
|
||||
} while (iter.Next(PageIteratorLevel.TextLine, PageIteratorLevel.Word));
|
||||
|
||||
if (words.Count > 0)
|
||||
{
|
||||
var lineText = string.Join(" ", words.Select(w => w.Text));
|
||||
lines.Add(new OcrLineResult { Text = lineText, Words = words });
|
||||
}
|
||||
} while (iter.Next(PageIteratorLevel.Block, PageIteratorLevel.TextLine));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static (byte[] gray, byte[] argb, int stride) BitmapToGrayAndArgb(Bitmap bmp)
|
||||
{
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] argb = new byte[data.Stride * h];
|
||||
Marshal.Copy(data.Scan0, argb, 0, argb.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)((argb[i] + argb[i + 1] + argb[i + 2]) / 3);
|
||||
}
|
||||
return (gray, argb, stride);
|
||||
}
|
||||
|
||||
public static SdImageFormat GetImageFormat(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => SdImageFormat.Jpeg,
|
||||
".bmp" => SdImageFormat.Bmp,
|
||||
_ => SdImageFormat.Png,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
namespace OcrDaemon;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
class Request
|
||||
{
|
||||
[JsonPropertyName("cmd")]
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
[JsonPropertyName("region")]
|
||||
public RegionRect? Region { get; set; }
|
||||
|
||||
[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; }
|
||||
|
||||
[JsonPropertyName("targetRow")]
|
||||
public int TargetRow { get; set; } = -1;
|
||||
|
||||
[JsonPropertyName("targetCol")]
|
||||
public int TargetCol { get; set; } = -1;
|
||||
|
||||
[JsonPropertyName("engine")]
|
||||
public string? Engine { get; set; }
|
||||
|
||||
[JsonPropertyName("preprocess")]
|
||||
public string? Preprocess { get; set; }
|
||||
|
||||
[JsonPropertyName("params")]
|
||||
public DiffOcrParams? Params { get; set; }
|
||||
|
||||
[JsonPropertyName("edgeParams")]
|
||||
public EdgeOcrParams? EdgeParams { get; set; }
|
||||
|
||||
[JsonPropertyName("cursorX")]
|
||||
public int? CursorX { get; set; }
|
||||
|
||||
[JsonPropertyName("cursorY")]
|
||||
public int? CursorY { get; set; }
|
||||
}
|
||||
|
||||
class RegionRect
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
}
|
||||
|
||||
class ReadyResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("ready")]
|
||||
public bool Ready => true;
|
||||
}
|
||||
|
||||
class OkResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
}
|
||||
|
||||
class ErrorResponse(string message)
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => false;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string Error => message;
|
||||
}
|
||||
|
||||
class OcrResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lines")]
|
||||
public List<OcrLineResult> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
class DiffOcrResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lines")]
|
||||
public List<OcrLineResult> Lines { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("region")]
|
||||
public RegionRect? Region { get; set; }
|
||||
}
|
||||
|
||||
class OcrLineResult
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("words")]
|
||||
public List<OcrWordResult> Words { get; set; } = [];
|
||||
}
|
||||
|
||||
class OcrWordResult
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
}
|
||||
|
||||
class CaptureResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; } = "";
|
||||
}
|
||||
|
||||
class GridResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("cells")]
|
||||
public List<List<bool>> Cells { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public List<GridItem>? Items { get; set; }
|
||||
|
||||
[JsonPropertyName("matches")]
|
||||
public List<GridMatch>? Matches { get; set; }
|
||||
}
|
||||
|
||||
class GridItem
|
||||
{
|
||||
[JsonPropertyName("row")]
|
||||
public int Row { get; set; }
|
||||
|
||||
[JsonPropertyName("col")]
|
||||
public int Col { get; set; }
|
||||
|
||||
[JsonPropertyName("w")]
|
||||
public int W { get; set; }
|
||||
|
||||
[JsonPropertyName("h")]
|
||||
public int H { get; set; }
|
||||
}
|
||||
|
||||
class GridMatch
|
||||
{
|
||||
[JsonPropertyName("row")]
|
||||
public int Row { get; set; }
|
||||
|
||||
[JsonPropertyName("col")]
|
||||
public int Col { get; set; }
|
||||
|
||||
[JsonPropertyName("similarity")]
|
||||
public double Similarity { 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; }
|
||||
}
|
||||
|
||||
class TemplateMatchResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("found")]
|
||||
public bool Found { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
sealed class DiffCropParams
|
||||
{
|
||||
[JsonPropertyName("diffThresh")]
|
||||
public int DiffThresh { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.4;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
|
||||
public override string ToString() =>
|
||||
$"diffThresh={DiffThresh} maxGap={MaxGap} trimCutoff={TrimCutoff:F2} rowThreshDiv={RowThreshDiv} colThreshDiv={ColThreshDiv} ocrPad={OcrPad}";
|
||||
}
|
||||
|
||||
sealed class OcrParams
|
||||
{
|
||||
// preprocessing
|
||||
[JsonPropertyName("kernelSize")]
|
||||
public int KernelSize { get; set; } = 41;
|
||||
|
||||
[JsonPropertyName("upscale")]
|
||||
public int Upscale { get; set; } = 2;
|
||||
|
||||
[JsonPropertyName("useBackgroundSub")]
|
||||
public bool UseBackgroundSub { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("dimPercentile")]
|
||||
public int DimPercentile { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("textThresh")]
|
||||
public int TextThresh { get; set; } = 60;
|
||||
|
||||
[JsonPropertyName("softThreshold")]
|
||||
public bool SoftThreshold { get; set; } = false;
|
||||
|
||||
// Tesseract-specific
|
||||
[JsonPropertyName("usePerLineOcr")]
|
||||
public bool UsePerLineOcr { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("lineGapTolerance")]
|
||||
public int LineGapTolerance { get; set; } = 10;
|
||||
|
||||
[JsonPropertyName("linePadY")]
|
||||
public int LinePadY { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("psm")]
|
||||
public int Psm { get; set; } = 6;
|
||||
|
||||
// post-merge / Python engine tuning
|
||||
[JsonPropertyName("mergeGap")]
|
||||
public int MergeGap { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("linkThreshold")]
|
||||
public double? LinkThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("textThreshold")]
|
||||
public double? TextThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("lowText")]
|
||||
public double? LowText { get; set; }
|
||||
|
||||
[JsonPropertyName("widthThs")]
|
||||
public double? WidthThs { get; set; }
|
||||
|
||||
[JsonPropertyName("paragraph")]
|
||||
public bool? Paragraph { get; set; }
|
||||
|
||||
public override string ToString() =>
|
||||
UseBackgroundSub
|
||||
? $"bgSub dimPct={DimPercentile} textThresh={TextThresh} soft={SoftThreshold} upscale={Upscale} mergeGap={MergeGap}"
|
||||
: $"topHat kernel={KernelSize} upscale={Upscale} mergeGap={MergeGap}";
|
||||
}
|
||||
|
||||
sealed class DiffOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public DiffCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
|
||||
public override string ToString() => $"[{Crop}] [{Ocr}]";
|
||||
}
|
||||
|
||||
sealed class EdgeCropParams
|
||||
{
|
||||
[JsonPropertyName("darkThresh")]
|
||||
public int DarkThresh { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("minDarkRun")]
|
||||
public int MinDarkRun { get; set; } = 200;
|
||||
|
||||
[JsonPropertyName("runGapTolerance")]
|
||||
public int RunGapTolerance { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.3;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
|
||||
public override string ToString() =>
|
||||
$"darkThresh={DarkThresh} minRun={MinDarkRun} runGap={RunGapTolerance} maxGap={MaxGap} trimCutoff={TrimCutoff:F2} rowDiv={RowThreshDiv} colDiv={ColThreshDiv}";
|
||||
}
|
||||
|
||||
sealed class EdgeOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public EdgeCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
|
||||
public override string ToString() => $"[{Crop}] [{Ocr}]";
|
||||
}
|
||||
|
||||
class TestCase
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("fullImage")]
|
||||
public string FullImage { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("expected")]
|
||||
public List<string> Expected { get; set; } = [];
|
||||
}
|
||||
|
||||
class TestCaseResult
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public bool Passed { get; set; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; set; }
|
||||
|
||||
[JsonPropertyName("matched")]
|
||||
public List<string> Matched { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("missed")]
|
||||
public List<string> Missed { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("extra")]
|
||||
public List<string> Extra { get; set; } = [];
|
||||
}
|
||||
|
||||
class TestResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public int Passed { get; set; }
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
public int Failed { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public List<TestCaseResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
class TuneResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("bestScore")]
|
||||
public double BestScore { get; set; }
|
||||
|
||||
[JsonPropertyName("bestParams")]
|
||||
public DiffOcrParams BestParams { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("iterations")]
|
||||
public int Iterations { get; set; }
|
||||
}
|
||||
|
||||
// ── Crop test models ────────────────────────────────────────────────────────
|
||||
|
||||
class PointXY
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
}
|
||||
|
||||
class CropTestCase
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("snapshotImage")]
|
||||
public string SnapshotImage { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("topLeft")]
|
||||
public PointXY TopLeft { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("bottomRight")]
|
||||
public PointXY BottomRight { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("cursorX")]
|
||||
public int? CursorX { get; set; }
|
||||
|
||||
[JsonPropertyName("cursorY")]
|
||||
public int? CursorY { get; set; }
|
||||
}
|
||||
|
||||
class CropTestResult
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("iou")]
|
||||
public double IoU { get; set; }
|
||||
|
||||
[JsonPropertyName("expected")]
|
||||
public RegionRect Expected { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("actual")]
|
||||
public RegionRect? Actual { get; set; }
|
||||
|
||||
[JsonPropertyName("deltaTop")]
|
||||
public int DeltaTop { get; set; }
|
||||
|
||||
[JsonPropertyName("deltaLeft")]
|
||||
public int DeltaLeft { get; set; }
|
||||
|
||||
[JsonPropertyName("deltaRight")]
|
||||
public int DeltaRight { get; set; }
|
||||
|
||||
[JsonPropertyName("deltaBottom")]
|
||||
public int DeltaBottom { get; set; }
|
||||
}
|
||||
|
||||
class CropTestResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string Method { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("avgIoU")]
|
||||
public double AvgIoU { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public List<CropTestResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
class CropTuneResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("bestAvgIoU")]
|
||||
public double BestAvgIoU { get; set; }
|
||||
|
||||
[JsonPropertyName("bestParams")]
|
||||
public DiffCropParams BestParams { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("iterations")]
|
||||
public int Iterations { get; set; }
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
|
||||
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.20250507" />
|
||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
<PackageReference Include="Tesseract" Version="5.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="tessdata\eng.traineddata">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="tessdata\poe2.traineddata" Condition="Exists('tessdata\poe2.traineddata')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="tessdata\cases.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="tessdata\crop.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="tessdata\poe2.user-words" Condition="Exists('tessdata\poe2.user-words')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="tessdata\poe2.user-patterns" Condition="Exists('tessdata\poe2.user-patterns')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="tessdata\images\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,916 +0,0 @@
|
|||
namespace OcrDaemon;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
using OpenCvSharp;
|
||||
using OpenCvSharp.Extensions;
|
||||
using Tesseract;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
class OcrHandler(TesseractEngine engine)
|
||||
{
|
||||
private Bitmap? _referenceFrame;
|
||||
private RegionRect? _referenceRegion;
|
||||
|
||||
public object HandleOcr(Request req)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
using var pix = ImageUtils.BitmapToPix(bitmap);
|
||||
using var page = engine.Process(pix);
|
||||
|
||||
var text = page.GetText();
|
||||
var lines = ImageUtils.ExtractLinesFromPage(page, offsetX: 0, offsetY: 0);
|
||||
return new OcrResponse { Text = text, Lines = lines };
|
||||
}
|
||||
|
||||
public object HandleScreenshot(Request req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Path))
|
||||
return new ErrorResponse("screenshot command requires 'path'");
|
||||
|
||||
// If a reference frame exists, save that (same image used for diff-ocr).
|
||||
// Otherwise capture a new frame.
|
||||
var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
var format = ImageUtils.GetImageFormat(req.Path);
|
||||
var dir = Path.GetDirectoryName(req.Path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
bitmap.Save(req.Path, format);
|
||||
if (bitmap != _referenceFrame) bitmap.Dispose();
|
||||
return new OkResponse();
|
||||
}
|
||||
|
||||
public object HandleCapture(Request req)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, SdImageFormat.Png);
|
||||
var base64 = Convert.ToBase64String(ms.ToArray());
|
||||
return new CaptureResponse { Image = base64 };
|
||||
}
|
||||
|
||||
public object HandleSnapshot(Request req)
|
||||
{
|
||||
_referenceFrame?.Dispose();
|
||||
_referenceFrame = ScreenCapture.CaptureOrLoad(req.File, req.Region);
|
||||
_referenceRegion = req.Region == null
|
||||
? null
|
||||
: new RegionRect { X = req.Region.X, Y = req.Region.Y, Width = req.Region.Width, Height = req.Region.Height };
|
||||
return new OkResponse();
|
||||
}
|
||||
|
||||
public object HandleDiffOcr(Request req) => HandleDiffOcr(req, req.Threshold > 0
|
||||
? new DiffOcrParams { Crop = new DiffCropParams { DiffThresh = req.Threshold } }
|
||||
: new DiffOcrParams());
|
||||
|
||||
/// <summary>
|
||||
/// Diff detection + crop only. Returns the raw tooltip crop bitmap and region,
|
||||
/// or null if no tooltip detected. Caller is responsible for disposing the bitmap.
|
||||
/// </summary>
|
||||
public (Bitmap cropped, Bitmap refCropped, Bitmap current, RegionRect region)? DiffCrop(Request req, DiffCropParams c)
|
||||
{
|
||||
if (_referenceFrame == null)
|
||||
return null;
|
||||
|
||||
var diffRegion = req.Region ?? _referenceRegion;
|
||||
int baseX = diffRegion?.X ?? 0;
|
||||
int baseY = diffRegion?.Y ?? 0;
|
||||
var current = ScreenCapture.CaptureOrLoad(req.File, diffRegion);
|
||||
|
||||
Bitmap refForDiff = _referenceFrame;
|
||||
bool disposeRef = false;
|
||||
|
||||
if (diffRegion != null)
|
||||
{
|
||||
if (_referenceRegion == null)
|
||||
{
|
||||
var croppedRef = CropBitmap(_referenceFrame, diffRegion);
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
else if (!RegionsEqual(diffRegion, _referenceRegion))
|
||||
{
|
||||
int offX = diffRegion.X - _referenceRegion.X;
|
||||
int offY = diffRegion.Y - _referenceRegion.Y;
|
||||
if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
var croppedRef = CropBitmap(_referenceFrame, new RegionRect
|
||||
{
|
||||
X = offX,
|
||||
Y = offY,
|
||||
Width = diffRegion.Width,
|
||||
Height = diffRegion.Height,
|
||||
});
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
}
|
||||
|
||||
int w = Math.Min(refForDiff.Width, current.Width);
|
||||
int h = Math.Min(refForDiff.Height, current.Height);
|
||||
|
||||
var refData = refForDiff.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] refPx = new byte[refData.Stride * h];
|
||||
Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length);
|
||||
refForDiff.UnlockBits(refData);
|
||||
int stride = refData.Stride;
|
||||
|
||||
var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] curPx = new byte[curData.Stride * h];
|
||||
Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length);
|
||||
current.UnlockBits(curData);
|
||||
|
||||
int diffThresh = c.DiffThresh;
|
||||
|
||||
// Pass 1: parallel row diff — compute rowCounts[] directly, no changed[] array
|
||||
int[] rowCounts = new int[h];
|
||||
Parallel.For(0, h, y =>
|
||||
{
|
||||
int count = 0;
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
count++;
|
||||
}
|
||||
rowCounts[y] = count;
|
||||
});
|
||||
|
||||
int totalChanged = 0;
|
||||
for (int y = 0; y < h; y++) totalChanged += rowCounts[y];
|
||||
|
||||
if (totalChanged == 0)
|
||||
{
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int maxGap = c.MaxGap;
|
||||
int rowThresh = w / c.RowThreshDiv;
|
||||
int bestRowStart = 0, bestRowEnd = 0, bestRowLen = 0;
|
||||
int curRowStart = -1, lastActiveRow = -1;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
if (rowCounts[y] >= rowThresh)
|
||||
{
|
||||
if (curRowStart < 0) curRowStart = y;
|
||||
lastActiveRow = y;
|
||||
}
|
||||
else if (curRowStart >= 0 && y - lastActiveRow > maxGap)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
curRowStart = -1;
|
||||
}
|
||||
}
|
||||
if (curRowStart >= 0)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
}
|
||||
|
||||
// Pass 2: parallel column diff — only within the row range, recompute from raw pixels
|
||||
int[] colCounts = new int[w];
|
||||
int rowRangeLen = bestRowEnd - bestRowStart + 1;
|
||||
if (rowRangeLen <= 200)
|
||||
{
|
||||
for (int y = bestRowStart; y <= bestRowEnd; y++)
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
colCounts[x]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Parallel.For(bestRowStart, bestRowEnd + 1,
|
||||
() => new int[w],
|
||||
(y, _, localCols) =>
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
localCols[x]++;
|
||||
}
|
||||
return localCols;
|
||||
},
|
||||
localCols =>
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
Interlocked.Add(ref colCounts[x], localCols[x]);
|
||||
});
|
||||
}
|
||||
|
||||
int tooltipHeight = bestRowEnd - bestRowStart + 1;
|
||||
int colThresh = tooltipHeight / c.ColThreshDiv;
|
||||
|
||||
int bestColStart = 0, bestColEnd = 0, bestColLen = 0;
|
||||
int curColStart = -1, lastActiveCol = -1;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
if (colCounts[x] >= colThresh)
|
||||
{
|
||||
if (curColStart < 0) curColStart = x;
|
||||
lastActiveCol = x;
|
||||
}
|
||||
else if (curColStart >= 0 && x - lastActiveCol > maxGap)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
curColStart = -1;
|
||||
}
|
||||
}
|
||||
if (curColStart >= 0)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($" diff-ocr: changed={totalChanged} rows={bestRowStart}-{bestRowEnd}({bestRowLen}) cols={bestColStart}-{bestColEnd}({bestColLen}) rowThresh={rowThresh} colThresh={colThresh}");
|
||||
|
||||
if (bestRowLen < 50 || bestColLen < 50)
|
||||
{
|
||||
Console.Error.WriteLine($" diff-ocr: no tooltip-sized region found (rows={bestRowLen}, cols={bestColLen})");
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int minX = bestColStart;
|
||||
int minY = bestRowStart;
|
||||
int maxX = Math.Min(bestColEnd, w - 1);
|
||||
int maxY = Math.Min(bestRowEnd, h - 1);
|
||||
|
||||
// Boundary extension: scan outward from detected edges with a relaxed threshold
|
||||
// to capture low-signal regions (e.g. ornamental tooltip headers)
|
||||
int extRowThresh = Math.Max(1, rowThresh / 4);
|
||||
int extColThresh = Math.Max(1, colThresh / 4);
|
||||
|
||||
int extTop = Math.Max(0, minY - maxGap);
|
||||
for (int y = minY - 1; y >= extTop; y--)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) minY = y;
|
||||
else break;
|
||||
}
|
||||
int extBottom = Math.Min(h - 1, maxY + maxGap);
|
||||
for (int y = maxY + 1; y <= extBottom; y++)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) maxY = y;
|
||||
else break;
|
||||
}
|
||||
int extLeft = Math.Max(0, minX - maxGap);
|
||||
for (int x = minX - 1; x >= extLeft; x--)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) minX = x;
|
||||
else break;
|
||||
}
|
||||
int extRight = Math.Min(w - 1, maxX + maxGap);
|
||||
for (int x = maxX + 1; x <= extRight; x++)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) maxX = x;
|
||||
else break;
|
||||
}
|
||||
|
||||
// Trim low-density edges on both axes to avoid oversized crops.
|
||||
int colSpan = maxX - minX + 1;
|
||||
if (colSpan > 50)
|
||||
{
|
||||
int q1 = minX + colSpan / 4;
|
||||
int q3 = minX + colSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int x = q1; x <= q3; x++) { midSum += colCounts[x]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minX < maxX - 50 && colCounts[minX] < cutoff)
|
||||
minX++;
|
||||
while (maxX > minX + 50 && colCounts[maxX] < cutoff)
|
||||
maxX--;
|
||||
}
|
||||
|
||||
int rowSpan = maxY - minY + 1;
|
||||
if (rowSpan > 50)
|
||||
{
|
||||
int q1 = minY + rowSpan / 4;
|
||||
int q3 = minY + rowSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int y = q1; y <= q3; y++) { midSum += rowCounts[y]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minY < maxY - 50 && rowCounts[minY] < cutoff)
|
||||
minY++;
|
||||
while (maxY > minY + 50 && rowCounts[maxY] < cutoff)
|
||||
maxY--;
|
||||
}
|
||||
int rw = maxX - minX + 1;
|
||||
int rh = maxY - minY + 1;
|
||||
|
||||
var cropped = CropFromBytes(curPx, stride, minX, minY, rw, rh);
|
||||
var refCropped = CropFromBytes(refPx, stride, minX, minY, rw, rh);
|
||||
var region = new RegionRect { X = baseX + minX, Y = baseY + minY, Width = rw, Height = rh };
|
||||
|
||||
Console.Error.WriteLine($" diff-ocr: tooltip region ({minX},{minY}) {rw}x{rh}");
|
||||
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return (cropped, refCropped, current, region);
|
||||
}
|
||||
|
||||
private static bool RegionsEqual(RegionRect a, RegionRect b) =>
|
||||
a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
|
||||
|
||||
private static Bitmap? CropBitmap(Bitmap src, RegionRect region)
|
||||
{
|
||||
int cx = Math.Max(0, region.X);
|
||||
int cy = Math.Max(0, region.Y);
|
||||
int cw = Math.Min(region.Width, src.Width - cx);
|
||||
int ch = Math.Min(region.Height, src.Height - cy);
|
||||
if (cw <= 0 || ch <= 0)
|
||||
return null;
|
||||
return src.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
|
||||
}
|
||||
|
||||
public object HandleDiffOcr(Request req, DiffOcrParams p)
|
||||
{
|
||||
if (_referenceFrame == null)
|
||||
return new ErrorResponse("No reference snapshot stored. Send 'snapshot' first.");
|
||||
|
||||
var cropResult = DiffCrop(req, p.Crop);
|
||||
if (cropResult == null)
|
||||
return new OcrResponse { Text = "", Lines = [] };
|
||||
|
||||
var (cropped, refCropped, current, region) = cropResult.Value;
|
||||
using var _current = current;
|
||||
using var _cropped = cropped;
|
||||
using var _refCropped = refCropped;
|
||||
bool debug = req.Debug;
|
||||
int minX = region.X, minY = region.Y, rw = region.Width, rh = region.Height;
|
||||
|
||||
// Save raw crop if path is provided
|
||||
if (!string.IsNullOrEmpty(req.Path))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(req.Path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
cropped.Save(req.Path, ImageUtils.GetImageFormat(req.Path));
|
||||
if (debug) Console.Error.WriteLine($" diff-ocr: saved raw to {req.Path}");
|
||||
}
|
||||
|
||||
// Pre-process for OCR — get Mat for per-line detection and padding
|
||||
var ocr = p.Ocr;
|
||||
Mat processedMat;
|
||||
if (ocr.UseBackgroundSub)
|
||||
{
|
||||
processedMat = ImagePreprocessor.PreprocessWithBackgroundSubMat(cropped, refCropped, ocr.DimPercentile, ocr.TextThresh, ocr.Upscale, ocr.SoftThreshold);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var topHatBmp = ImagePreprocessor.PreprocessForOcr(cropped, ocr.KernelSize, ocr.Upscale);
|
||||
processedMat = BitmapConverter.ToMat(topHatBmp);
|
||||
}
|
||||
using var _processedMat = processedMat; // ensure disposal
|
||||
|
||||
// Save fullscreen and preprocessed versions alongside raw
|
||||
if (!string.IsNullOrEmpty(req.Path))
|
||||
{
|
||||
var ext = Path.GetExtension(req.Path);
|
||||
var fullPath = Path.ChangeExtension(req.Path, ".full" + ext);
|
||||
current.Save(fullPath, ImageUtils.GetImageFormat(fullPath));
|
||||
if (debug) Console.Error.WriteLine($" diff-ocr: saved fullscreen to {fullPath}");
|
||||
|
||||
var prePath = Path.ChangeExtension(req.Path, ".pre" + ext);
|
||||
using var preBmp = BitmapConverter.ToBitmap(processedMat);
|
||||
preBmp.Save(prePath, ImageUtils.GetImageFormat(prePath));
|
||||
if (debug) Console.Error.WriteLine($" diff-ocr: saved preprocessed to {prePath}");
|
||||
}
|
||||
|
||||
int pad = p.Crop.OcrPad;
|
||||
int upscale = ocr.Upscale > 0 ? ocr.Upscale : 1;
|
||||
var lines = new List<OcrLineResult>();
|
||||
|
||||
// Per-line OCR: detect text lines via horizontal projection, OCR each individually
|
||||
if (ocr.UsePerLineOcr)
|
||||
{
|
||||
// DetectTextLines needs binary input; if soft threshold produced grayscale, binarize a copy
|
||||
int minRowPx = Math.Max(processedMat.Cols / 200, 3);
|
||||
using var detectionMat = ocr.SoftThreshold ? new Mat() : null;
|
||||
if (ocr.SoftThreshold)
|
||||
Cv2.Threshold(processedMat, detectionMat!, 128, 255, ThresholdTypes.Binary);
|
||||
var lineDetectInput = ocr.SoftThreshold ? detectionMat! : processedMat;
|
||||
var textLines = ImagePreprocessor.DetectTextLines(lineDetectInput, minRowPixels: minRowPx, gapTolerance: ocr.LineGapTolerance * upscale);
|
||||
if (debug) Console.Error.WriteLine($" diff-ocr: detected {textLines.Count} text lines");
|
||||
|
||||
if (textLines.Count > 0)
|
||||
{
|
||||
int linePadY = ocr.LinePadY;
|
||||
foreach (var (yStart, yEnd) in textLines)
|
||||
{
|
||||
int y0 = Math.Max(yStart - linePadY, 0);
|
||||
int y1 = Math.Min(yEnd + linePadY, processedMat.Rows - 1);
|
||||
int lineH = y1 - y0 + 1;
|
||||
|
||||
// Crop line strip (full width)
|
||||
using var lineStrip = new Mat(processedMat, new OpenCvSharp.Rect(0, y0, processedMat.Cols, lineH));
|
||||
|
||||
// Add whitespace padding around the line
|
||||
using var padded = new Mat();
|
||||
Cv2.CopyMakeBorder(lineStrip, padded, pad, pad, pad, pad, BorderTypes.Constant, Scalar.White);
|
||||
|
||||
using var lineBmp = BitmapConverter.ToBitmap(padded);
|
||||
using var linePix = ImageUtils.BitmapToPix(lineBmp);
|
||||
using var linePage = engine.Process(linePix, (PageSegMode)ocr.Psm);
|
||||
|
||||
// Extract words, adjusting coordinates back to screen space
|
||||
// Word coords are in padded image space → subtract pad, add line offset, scale to original, add region offset
|
||||
var lineWords = new List<OcrWordResult>();
|
||||
using var iter = linePage.GetIterator();
|
||||
if (iter != null)
|
||||
{
|
||||
iter.Begin();
|
||||
do
|
||||
{
|
||||
var wordText = iter.GetText(PageIteratorLevel.Word);
|
||||
if (string.IsNullOrWhiteSpace(wordText)) continue;
|
||||
|
||||
float conf = iter.GetConfidence(PageIteratorLevel.Word);
|
||||
if (conf < 50) continue;
|
||||
|
||||
if (iter.TryGetBoundingBox(PageIteratorLevel.Word, out var bounds))
|
||||
{
|
||||
lineWords.Add(new OcrWordResult
|
||||
{
|
||||
Text = wordText.Trim(),
|
||||
X = (bounds.X1 - pad + 0) / upscale + minX,
|
||||
Y = (bounds.Y1 - pad + y0) / upscale + minY,
|
||||
Width = bounds.Width / upscale,
|
||||
Height = bounds.Height / upscale,
|
||||
});
|
||||
}
|
||||
} while (iter.Next(PageIteratorLevel.TextLine, PageIteratorLevel.Word));
|
||||
}
|
||||
|
||||
if (lineWords.Count > 0)
|
||||
{
|
||||
var lineText = string.Join(" ", lineWords.Select(w => w.Text));
|
||||
lines.Add(new OcrLineResult { Text = lineText, Words = lineWords });
|
||||
}
|
||||
}
|
||||
|
||||
var text = string.Join("\n", lines.Select(l => l.Text)) + "\n";
|
||||
return new DiffOcrResponse
|
||||
{
|
||||
Text = text,
|
||||
Lines = lines,
|
||||
Region = new RegionRect { X = minX, Y = minY, Width = rw, Height = rh },
|
||||
};
|
||||
}
|
||||
|
||||
if (debug) Console.Error.WriteLine(" diff-ocr: no text lines detected, falling back to whole-block OCR");
|
||||
}
|
||||
|
||||
// Whole-block fallback: add padding and use configurable PSM
|
||||
{
|
||||
using var padded = new Mat();
|
||||
Cv2.CopyMakeBorder(processedMat, padded, pad, pad, pad, pad, BorderTypes.Constant, Scalar.White);
|
||||
using var bmp = BitmapConverter.ToBitmap(padded);
|
||||
using var pix = ImageUtils.BitmapToPix(bmp);
|
||||
using var page = engine.Process(pix, (PageSegMode)ocr.Psm);
|
||||
|
||||
var text = page.GetText();
|
||||
// Adjust word coordinates: subtract padding offset
|
||||
lines = ImageUtils.ExtractLinesFromPage(page, offsetX: minX - pad / upscale, offsetY: minY - pad / upscale);
|
||||
|
||||
return new DiffOcrResponse
|
||||
{
|
||||
Text = text,
|
||||
Lines = lines,
|
||||
Region = new RegionRect { X = minX, Y = minY, Width = rw, Height = rh },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run Tesseract OCR on an already-preprocessed bitmap. Converts to Mat, pads,
|
||||
/// runs PSM-6, and adjusts word coordinates to screen space using the supplied region.
|
||||
/// </summary>
|
||||
public DiffOcrResponse RunTesseractOnBitmap(Bitmap processedBmp, RegionRect region, int pad = 10, int upscale = 2, int psm = 6)
|
||||
{
|
||||
using var processedMat = BitmapConverter.ToMat(processedBmp);
|
||||
using var padded = new Mat();
|
||||
Cv2.CopyMakeBorder(processedMat, padded, pad, pad, pad, pad, BorderTypes.Constant, Scalar.White);
|
||||
using var bmp = BitmapConverter.ToBitmap(padded);
|
||||
using var pix = ImageUtils.BitmapToPix(bmp);
|
||||
using var page = engine.Process(pix, (PageSegMode)psm);
|
||||
|
||||
var text = page.GetText();
|
||||
int effUpscale = upscale > 0 ? upscale : 1;
|
||||
var lines = ImageUtils.ExtractLinesFromPage(page,
|
||||
offsetX: region.X - pad / effUpscale,
|
||||
offsetY: region.Y - pad / effUpscale);
|
||||
|
||||
return new DiffOcrResponse
|
||||
{
|
||||
Text = text,
|
||||
Lines = lines,
|
||||
Region = region,
|
||||
};
|
||||
}
|
||||
|
||||
public object HandleTest(Request req) => RunTestCases(new DiffOcrParams(), verbose: true);
|
||||
|
||||
private static DiffOcrParams CloneParams(DiffOcrParams p)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(p);
|
||||
return JsonSerializer.Deserialize<DiffOcrParams>(json)!;
|
||||
}
|
||||
|
||||
public object HandleTune(Request req)
|
||||
{
|
||||
int totalEvals = 0;
|
||||
|
||||
// --- Phase A: Tune crop params ---
|
||||
Console.Error.WriteLine("\n========== Phase A: Crop Params ==========");
|
||||
var best = new DiffOcrParams();
|
||||
double bestScore = TuneCropParams(best, ref totalEvals);
|
||||
|
||||
// --- Phase B: Tune OCR params (top-hat) ---
|
||||
Console.Error.WriteLine("\n========== Phase B: OCR — Top-Hat ==========");
|
||||
var topHat = CloneParams(best);
|
||||
topHat.Ocr.UseBackgroundSub = false;
|
||||
double topHatScore = TuneOcrParams(topHat, ref totalEvals, tuneTopHat: true, tuneBgSub: false);
|
||||
|
||||
// --- Phase C: Tune OCR params (background-subtraction) ---
|
||||
Console.Error.WriteLine("\n========== Phase C: OCR — Background Subtraction ==========");
|
||||
var bgSub = CloneParams(best);
|
||||
bgSub.Ocr.UseBackgroundSub = true;
|
||||
double bgSubScore = TuneOcrParams(bgSub, ref totalEvals, tuneTopHat: false, tuneBgSub: true);
|
||||
|
||||
// Pick the winner
|
||||
var winner = bgSubScore > topHatScore ? bgSub : topHat;
|
||||
double winnerScore = Math.Max(topHatScore, bgSubScore);
|
||||
|
||||
Console.Error.WriteLine($"\n========== Result ==========");
|
||||
Console.Error.WriteLine($" Top-Hat: {topHatScore:F3} {topHat}");
|
||||
Console.Error.WriteLine($" BgSub: {bgSubScore:F3} {bgSub}");
|
||||
Console.Error.WriteLine($" Winner: {(winner.Ocr.UseBackgroundSub ? "BgSub" : "TopHat")} evals={totalEvals}\n");
|
||||
|
||||
// Final verbose report with best params
|
||||
RunTestCases(winner, verbose: true);
|
||||
|
||||
return new TuneResponse
|
||||
{
|
||||
BestScore = winnerScore,
|
||||
BestParams = winner,
|
||||
Iterations = totalEvals,
|
||||
};
|
||||
}
|
||||
|
||||
private double TuneCropParams(DiffOcrParams best, ref int totalEvals)
|
||||
{
|
||||
double bestScore = ScoreParams(best);
|
||||
Console.Error.WriteLine($" baseline score={bestScore:F3} {best}\n");
|
||||
|
||||
var cropSweeps = new (string Name, int[] Values, Action<DiffCropParams, int> Set)[]
|
||||
{
|
||||
("diffThresh", [10, 15, 20, 25, 30, 40, 50, 60], (c, v) => c.DiffThresh = v),
|
||||
("rowThreshDiv", [10, 15, 20, 25, 30, 40, 50, 60], (c, v) => c.RowThreshDiv = v),
|
||||
("colThreshDiv", [5, 8, 10, 12, 15, 20, 25, 30], (c, v) => c.ColThreshDiv = v),
|
||||
("maxGap", [5, 8, 10, 12, 15, 20, 25, 30], (c, v) => c.MaxGap = v),
|
||||
("ocrPad", [0, 5, 10, 15, 20, 30], (c, v) => c.OcrPad = v),
|
||||
};
|
||||
|
||||
double[] trimValues = [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5];
|
||||
|
||||
const int maxRounds = 3;
|
||||
for (int round = 0; round < maxRounds; round++)
|
||||
{
|
||||
bool improved = false;
|
||||
Console.Error.WriteLine($"--- Round {round + 1} ---");
|
||||
|
||||
foreach (var (name, values, set) in cropSweeps)
|
||||
{
|
||||
Console.Error.Write($" {name}: ");
|
||||
int bestVal = 0;
|
||||
double bestValScore = -1;
|
||||
|
||||
foreach (int v in values)
|
||||
{
|
||||
var trial = CloneParams(best);
|
||||
set(trial.Crop, v);
|
||||
double score = ScoreParams(trial);
|
||||
totalEvals++;
|
||||
Console.Error.Write($"{v}={score:F3} ");
|
||||
if (score > bestValScore) { bestValScore = score; bestVal = v; }
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
|
||||
if (bestValScore > bestScore)
|
||||
{
|
||||
set(best.Crop, bestVal);
|
||||
bestScore = bestValScore;
|
||||
improved = true;
|
||||
Console.Error.WriteLine($" → {name}={bestVal} score={bestScore:F3}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep trimCutoff
|
||||
{
|
||||
Console.Error.Write($" trimCutoff: ");
|
||||
double bestTrim = best.Crop.TrimCutoff;
|
||||
double bestTrimScore = bestScore;
|
||||
|
||||
foreach (double v in trimValues)
|
||||
{
|
||||
var trial = CloneParams(best);
|
||||
trial.Crop.TrimCutoff = v;
|
||||
double score = ScoreParams(trial);
|
||||
totalEvals++;
|
||||
Console.Error.Write($"{v:F2}={score:F3} ");
|
||||
if (score > bestTrimScore) { bestTrimScore = score; bestTrim = v; }
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
|
||||
if (bestTrimScore > bestScore)
|
||||
{
|
||||
best.Crop.TrimCutoff = bestTrim;
|
||||
bestScore = bestTrimScore;
|
||||
improved = true;
|
||||
Console.Error.WriteLine($" → trimCutoff={bestTrim:F2} score={bestScore:F3}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($" End of round {round + 1}: score={bestScore:F3} {best}");
|
||||
if (!improved) break;
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
private double TuneOcrParams(DiffOcrParams best, ref int totalEvals, bool tuneTopHat, bool tuneBgSub)
|
||||
{
|
||||
double bestScore = ScoreParams(best);
|
||||
Console.Error.WriteLine($" baseline score={bestScore:F3} {best}\n");
|
||||
|
||||
var sharedOcrSweeps = new (string Name, int[] Values, Action<OcrParams, int> Set)[]
|
||||
{
|
||||
("upscale", [1, 2, 3], (o, v) => o.Upscale = v),
|
||||
("psm", [4, 6, 11, 13], (o, v) => o.Psm = v),
|
||||
};
|
||||
|
||||
// Top-hat specific
|
||||
var topHatSweeps = new (string Name, int[] Values, Action<OcrParams, int> Set)[]
|
||||
{
|
||||
("kernelSize", [11, 15, 19, 21, 25, 31, 35, 41, 51], (o, v) => o.KernelSize = v),
|
||||
};
|
||||
|
||||
// Background-subtraction specific
|
||||
var bgSubSweeps = new (string Name, int[] Values, Action<OcrParams, int> Set)[]
|
||||
{
|
||||
("dimPercentile", [5, 10, 15, 20, 25, 30, 40, 50], (o, v) => o.DimPercentile = v),
|
||||
("textThresh", [10, 15, 20, 25, 30, 40, 50, 60, 80], (o, v) => o.TextThresh = v),
|
||||
("lineGapTolerance", [3, 5, 8, 10, 15], (o, v) => o.LineGapTolerance = v),
|
||||
("linePadY", [5, 10, 15, 20], (o, v) => o.LinePadY = v),
|
||||
};
|
||||
|
||||
var allOcrSweeps = sharedOcrSweeps
|
||||
.Concat(tuneTopHat ? topHatSweeps : [])
|
||||
.Concat(tuneBgSub ? bgSubSweeps : [])
|
||||
.ToArray();
|
||||
|
||||
const int maxRounds = 3;
|
||||
for (int round = 0; round < maxRounds; round++)
|
||||
{
|
||||
bool improved = false;
|
||||
Console.Error.WriteLine($"--- Round {round + 1} ---");
|
||||
|
||||
foreach (var (name, values, set) in allOcrSweeps)
|
||||
{
|
||||
Console.Error.Write($" {name}: ");
|
||||
int bestVal = 0;
|
||||
double bestValScore = -1;
|
||||
|
||||
foreach (int v in values)
|
||||
{
|
||||
var trial = CloneParams(best);
|
||||
set(trial.Ocr, v);
|
||||
double score = ScoreParams(trial);
|
||||
totalEvals++;
|
||||
Console.Error.Write($"{v}={score:F3} ");
|
||||
if (score > bestValScore) { bestValScore = score; bestVal = v; }
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
|
||||
if (bestValScore > bestScore)
|
||||
{
|
||||
set(best.Ocr, bestVal);
|
||||
bestScore = bestValScore;
|
||||
improved = true;
|
||||
Console.Error.WriteLine($" → {name}={bestVal} score={bestScore:F3}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($" End of round {round + 1}: score={bestScore:F3} {best}");
|
||||
if (!improved) break;
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
/// <summary>Score a param set: average match ratio across all test cases (0-1).</summary>
|
||||
private double ScoreParams(DiffOcrParams p)
|
||||
{
|
||||
var result = RunTestCases(p, verbose: false);
|
||||
if (result is TestResponse tr && tr.Total > 0)
|
||||
return tr.Results.Average(r => r.Score);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private object RunTestCases(DiffOcrParams p, bool verbose)
|
||||
{
|
||||
var tessdataDir = Path.Combine(AppContext.BaseDirectory, "tessdata");
|
||||
var casesPath = Path.Combine(tessdataDir, "cases.json");
|
||||
if (!File.Exists(casesPath))
|
||||
return new ErrorResponse($"cases.json not found at {casesPath}");
|
||||
|
||||
var json = File.ReadAllText(casesPath);
|
||||
var cases = JsonSerializer.Deserialize<List<TestCase>>(json);
|
||||
if (cases == null || cases.Count == 0)
|
||||
return new ErrorResponse("No test cases found in cases.json");
|
||||
|
||||
var results = new List<TestCaseResult>();
|
||||
int passCount = 0;
|
||||
|
||||
foreach (var tc in cases)
|
||||
{
|
||||
if (verbose) Console.Error.WriteLine($"\n=== Test: {tc.Id} ===");
|
||||
|
||||
var fullPath = Path.Combine(tessdataDir, tc.FullImage);
|
||||
var imagePath = Path.Combine(tessdataDir, tc.Image);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
if (verbose) Console.Error.WriteLine($" SKIP: full image not found: {fullPath}");
|
||||
results.Add(new TestCaseResult { Id = tc.Id, Passed = false, Score = 0, Missed = tc.Expected });
|
||||
continue;
|
||||
}
|
||||
if (!File.Exists(imagePath))
|
||||
{
|
||||
if (verbose) Console.Error.WriteLine($" SKIP: tooltip image not found: {imagePath}");
|
||||
results.Add(new TestCaseResult { Id = tc.Id, Passed = false, Score = 0, Missed = tc.Expected });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the same pipeline: snapshot (reference) then diff-ocr (with tooltip)
|
||||
HandleSnapshot(new Request { File = fullPath });
|
||||
var diffResult = HandleDiffOcr(new Request { File = imagePath, Debug = verbose }, p);
|
||||
|
||||
// Extract actual lines from the response
|
||||
List<string> actualLines;
|
||||
if (diffResult is DiffOcrResponse diffResp)
|
||||
actualLines = diffResp.Lines.Select(l => l.Text.Trim()).Where(l => l.Length > 0).ToList();
|
||||
else if (diffResult is OcrResponse ocrResp)
|
||||
actualLines = ocrResp.Lines.Select(l => l.Text.Trim()).Where(l => l.Length > 0).ToList();
|
||||
else
|
||||
{
|
||||
if (verbose) Console.Error.WriteLine($" ERROR: unexpected response type");
|
||||
results.Add(new TestCaseResult { Id = tc.Id, Passed = false, Score = 0, Missed = tc.Expected });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuzzy match expected vs actual
|
||||
var matched = new List<string>();
|
||||
var missed = new List<string>();
|
||||
var usedActual = new HashSet<int>();
|
||||
|
||||
foreach (var expected in tc.Expected)
|
||||
{
|
||||
int bestIdx = -1;
|
||||
double bestSim = 0;
|
||||
for (int i = 0; i < actualLines.Count; i++)
|
||||
{
|
||||
if (usedActual.Contains(i)) continue;
|
||||
double sim = LevenshteinSimilarity(expected, actualLines[i]);
|
||||
if (sim > bestSim) { bestSim = sim; bestIdx = i; }
|
||||
}
|
||||
|
||||
if (bestIdx >= 0 && bestSim >= 0.75)
|
||||
{
|
||||
matched.Add(expected);
|
||||
usedActual.Add(bestIdx);
|
||||
if (verbose && bestSim < 1.0)
|
||||
Console.Error.WriteLine($" ~ {expected} → {actualLines[bestIdx]} (sim={bestSim:F2})");
|
||||
}
|
||||
else
|
||||
{
|
||||
missed.Add(expected);
|
||||
if (verbose)
|
||||
Console.Error.WriteLine($" MISS: {expected}" + (bestIdx >= 0 ? $" (best: {actualLines[bestIdx]}, sim={bestSim:F2})" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
var extra = actualLines.Where((_, i) => !usedActual.Contains(i)).ToList();
|
||||
if (verbose)
|
||||
foreach (var e in extra)
|
||||
Console.Error.WriteLine($" EXTRA: {e}");
|
||||
|
||||
double score = tc.Expected.Count > 0 ? (double)matched.Count / tc.Expected.Count : 1.0;
|
||||
bool passed = missed.Count == 0;
|
||||
if (passed) passCount++;
|
||||
|
||||
if (verbose)
|
||||
Console.Error.WriteLine($" Result: {(passed ? "PASS" : "FAIL")} matched={matched.Count}/{tc.Expected.Count} extra={extra.Count} score={score:F2}");
|
||||
|
||||
results.Add(new TestCaseResult
|
||||
{
|
||||
Id = tc.Id,
|
||||
Passed = passed,
|
||||
Score = score,
|
||||
Matched = matched,
|
||||
Missed = missed,
|
||||
Extra = extra,
|
||||
});
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
Console.Error.WriteLine($"\n=== Summary: {passCount}/{cases.Count} passed ===\n");
|
||||
|
||||
return new TestResponse
|
||||
{
|
||||
Passed = passCount,
|
||||
Failed = cases.Count - passCount,
|
||||
Total = cases.Count,
|
||||
Results = results,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast crop from raw pixel bytes — avoids slow GDI+ Bitmap.Clone().
|
||||
/// </summary>
|
||||
private static Bitmap CropFromBytes(byte[] px, int srcStride, int cropX, int cropY, int cropW, int cropH)
|
||||
{
|
||||
var bmp = new Bitmap(cropW, cropH, PixelFormat.Format32bppArgb);
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, cropW, cropH), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
|
||||
int dstStride = data.Stride;
|
||||
int rowBytes = cropW * 4;
|
||||
for (int y = 0; y < cropH; y++)
|
||||
{
|
||||
int srcOffset = (cropY + y) * srcStride + cropX * 4;
|
||||
Marshal.Copy(px, srcOffset, data.Scan0 + y * dstStride, rowBytes);
|
||||
}
|
||||
bmp.UnlockBits(data);
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private static double LevenshteinSimilarity(string a, string b)
|
||||
{
|
||||
a = a.ToLowerInvariant();
|
||||
b = b.ToLowerInvariant();
|
||||
if (a == b) return 1.0;
|
||||
|
||||
int la = a.Length, lb = b.Length;
|
||||
if (la == 0 || lb == 0) return 0.0;
|
||||
|
||||
var d = new int[la + 1, lb + 1];
|
||||
for (int i = 0; i <= la; i++) d[i, 0] = i;
|
||||
for (int j = 0; j <= lb; j++) d[0, j] = j;
|
||||
|
||||
for (int i = 1; i <= la; i++)
|
||||
for (int j = 1; j <= lb; j++)
|
||||
{
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
|
||||
}
|
||||
|
||||
return 1.0 - (double)d[la, lb] / Math.Max(la, lb);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
return OcrDaemon.Daemon.Run();
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
namespace OcrDaemon;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Tesseract;
|
||||
|
||||
static class TestRunner
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
string baseDir = AppContext.BaseDirectory;
|
||||
string? savePreDir = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "--save-pre", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
savePreDir = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
savePreDir = "processed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string casesPath = args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
|
||||
? args[0]
|
||||
: Path.Combine(baseDir, "tessdata", "cases.json");
|
||||
|
||||
if (!File.Exists(casesPath))
|
||||
{
|
||||
Console.Error.WriteLine($"cases.json not found: {casesPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(casesPath);
|
||||
var cases = JsonSerializer.Deserialize<List<TestCase>>(json, JsonOptions) ?? [];
|
||||
if (cases.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No test cases found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string tessdataPath = Path.Combine(baseDir, "tessdata");
|
||||
string tessLang = File.Exists(Path.Combine(tessdataPath, "poe2.traineddata")) ? "poe2" : "eng";
|
||||
|
||||
using var engine = new TesseractEngine(tessdataPath, tessLang, EngineMode.LstmOnly);
|
||||
engine.DefaultPageSegMode = PageSegMode.SingleBlock;
|
||||
engine.SetVariable("preserve_interword_spaces", "1");
|
||||
var ocrHandler = new OcrHandler(engine);
|
||||
|
||||
int totalExpected = 0;
|
||||
int totalMatched = 0;
|
||||
int caseFailures = 0;
|
||||
|
||||
string casesDir = Path.GetDirectoryName(casesPath) ?? baseDir;
|
||||
if (!string.IsNullOrEmpty(savePreDir))
|
||||
{
|
||||
if (!Path.IsPathRooted(savePreDir))
|
||||
savePreDir = Path.Combine(casesDir, savePreDir);
|
||||
if (!Directory.Exists(savePreDir))
|
||||
Directory.CreateDirectory(savePreDir);
|
||||
}
|
||||
foreach (var tc in cases)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tc.Image))
|
||||
{
|
||||
Console.Error.WriteLine($"[SKIP] {tc.Id}: missing image path");
|
||||
continue;
|
||||
}
|
||||
|
||||
string imagePath = Path.IsPathRooted(tc.Image)
|
||||
? tc.Image
|
||||
: Path.Combine(casesDir, tc.Image);
|
||||
|
||||
if (!File.Exists(imagePath))
|
||||
{
|
||||
Console.Error.WriteLine($"[SKIP] {tc.Id}: image not found: {imagePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
List<string> actualSet;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tc.BeforeImage))
|
||||
{
|
||||
string beforePath = Path.IsPathRooted(tc.BeforeImage)
|
||||
? tc.BeforeImage
|
||||
: Path.Combine(casesDir, tc.BeforeImage);
|
||||
|
||||
if (!File.Exists(beforePath))
|
||||
{
|
||||
Console.Error.WriteLine($"[SKIP] {tc.Id}: before image not found: {beforePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
ocrHandler.HandleSnapshot(new Request { File = beforePath });
|
||||
|
||||
string? savePath = null;
|
||||
if (!string.IsNullOrEmpty(savePreDir))
|
||||
savePath = Path.Combine(savePreDir, $"{tc.Id}.raw.png");
|
||||
|
||||
var response = ocrHandler.HandleDiffOcr(new Request
|
||||
{
|
||||
File = imagePath,
|
||||
Path = savePath,
|
||||
});
|
||||
|
||||
if (response is ErrorResponse err)
|
||||
{
|
||||
Console.Error.WriteLine($"[FAIL] {tc.Id}: {err.Error}");
|
||||
caseFailures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response is DiffOcrResponse diff)
|
||||
actualSet = BuildActualSet(diff.Text, diff.Lines);
|
||||
else if (response is OcrResponse ocr)
|
||||
actualSet = BuildActualSet(ocr.Text, ocr.Lines);
|
||||
else
|
||||
actualSet = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
using var bitmap = new Bitmap(imagePath);
|
||||
using var processed = ImagePreprocessor.PreprocessForOcr(bitmap);
|
||||
|
||||
if (!string.IsNullOrEmpty(savePreDir))
|
||||
{
|
||||
string outPath = Path.Combine(savePreDir, $"{tc.Id}.pre.png");
|
||||
processed.Save(outPath, System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
using var pix = ImageUtils.BitmapToPix(processed);
|
||||
using var page = engine.Process(pix);
|
||||
|
||||
var lines = ImageUtils.ExtractLinesFromPage(page, offsetX: 0, offsetY: 0);
|
||||
var actualLines = lines.Select(l => Normalize(l.Text)).Where(s => s.Length > 0).ToList();
|
||||
|
||||
var rawText = page.GetText() ?? string.Empty;
|
||||
var rawLines = rawText.Split('\n')
|
||||
.Select(Normalize)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
|
||||
actualSet = actualLines.Concat(rawLines).Distinct().ToList();
|
||||
}
|
||||
|
||||
var expectedLines = tc.Expected
|
||||
.Select(Normalize)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
|
||||
totalExpected += expectedLines.Count;
|
||||
int matched = expectedLines.Count(e => actualSet.Contains(e));
|
||||
totalMatched += matched;
|
||||
|
||||
if (matched < expectedLines.Count)
|
||||
{
|
||||
caseFailures++;
|
||||
Console.Error.WriteLine($"[FAIL] {tc.Id}: matched {matched}/{expectedLines.Count}");
|
||||
var missing = expectedLines.Where(e => !actualSet.Contains(e)).ToList();
|
||||
foreach (var line in missing)
|
||||
Console.Error.WriteLine($" missing: {line}");
|
||||
|
||||
Console.Error.WriteLine(" actual:");
|
||||
foreach (var line in actualSet)
|
||||
Console.Error.WriteLine($" > {line}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine($"[OK] {tc.Id}: matched {matched}/{expectedLines.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Summary: matched {totalMatched}/{totalExpected} lines, failed cases: {caseFailures}");
|
||||
return caseFailures == 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
private static string Normalize(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
|
||||
var chars = input.Trim().ToLowerInvariant().ToCharArray();
|
||||
var sb = new System.Text.StringBuilder(chars.Length);
|
||||
bool inSpace = false;
|
||||
foreach (char c in chars)
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (!inSpace)
|
||||
{
|
||||
sb.Append(' ');
|
||||
inSpace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
inSpace = false;
|
||||
sb.Append(c);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static List<string> BuildActualSet(string text, List<OcrLineResult> lines)
|
||||
{
|
||||
var lineTexts = lines.Select(l => Normalize(l.Text)).Where(s => s.Length > 0).ToList();
|
||||
var textLines = (text ?? string.Empty).Split('\n')
|
||||
.Select(Normalize)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
return lineTexts.Concat(textLines).Distinct().ToList();
|
||||
}
|
||||
|
||||
private sealed class TestCase
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Image { get; set; } = "";
|
||||
public string? BeforeImage { get; set; }
|
||||
public List<string> Expected { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "vertex1",
|
||||
"image": "images/vertex1.png",
|
||||
"fullImage": "images/vertex-snapshot.png",
|
||||
"expected": [
|
||||
"The Vertex",
|
||||
"Tribal Mask",
|
||||
"Helmet",
|
||||
"Quality: +20%",
|
||||
"Evasion Rating: 79",
|
||||
"Energy Shield: 34",
|
||||
"Requires: Level 33",
|
||||
"16% Increased Life Regeneration Rate",
|
||||
"Has no Attribute Requirements",
|
||||
"+15% to Chaos Resistance",
|
||||
"Skill gems have no attribute requirements",
|
||||
"+3 to level of all skills",
|
||||
"15% increased mana cost efficiency",
|
||||
"Twice Corrupted",
|
||||
"\"A Queen should be seen, Admired, but never touched.\"",
|
||||
"- Atziri, Queen of the Vaal",
|
||||
"Asking Price:",
|
||||
"7x Divine Orb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vertex2",
|
||||
"image": "images/vertex2.png",
|
||||
"fullImage": "images/vertex-snapshot.png",
|
||||
"expected": [
|
||||
"The Vertex",
|
||||
"Tribal Mask",
|
||||
"Helmet",
|
||||
"Quality: +20%",
|
||||
"Evasion Rating: 182",
|
||||
"Energy Shield: 77",
|
||||
"Requires: Level 33",
|
||||
"+29 To Spirit",
|
||||
"+1 to Level of All Minion Skills",
|
||||
"Has no Attribute Requirements",
|
||||
"130% increased Evasion and Energy Shield",
|
||||
"27% Increased Critical Hit Chance",
|
||||
"+13% to Chaos Resistance",
|
||||
"+2 to level of all skills",
|
||||
"Twice Corrupted",
|
||||
"\"A Queen should be seen, Admired, but never touched.\"",
|
||||
"- Atziri, Queen of the Vaal",
|
||||
"Asking Price:",
|
||||
"35x Divine Orb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "raphpith1",
|
||||
"image": "images/raphpith.png",
|
||||
"fullImage": "images/raphpith-snapshot.png",
|
||||
"expected": [
|
||||
"RATHPITH GLOBE",
|
||||
"SACRED Focus",
|
||||
"Focus",
|
||||
"Quality: +20%",
|
||||
"Energy Shield: 104",
|
||||
"Requires: Level 75",
|
||||
"16% Increased Energy Shield",
|
||||
"+24 To Maximum Mana",
|
||||
"+5% to all Elemental Resistances",
|
||||
"NON-CHANNELLING SPELLS HAVE 3% INCREASED MAGNITUDE OF AlLMENTS PER 100 MAXIMUM LIFE",
|
||||
"NON-CHANNELLING SPELLS DEAL 6% INCREASED DAMAGE PER 100 MAXIMUM MANA",
|
||||
"+72 TO MAXIMUM LIFE",
|
||||
"NON-CHANNELLING SPELLS HAVE 3% INCREASED CRITICAL HIT CHANCE PER 100 MAXIMUM LIFE",
|
||||
"NON-CHANNELLING SPELLS DEAL 6% INCREASED DAMACE PER 100 MAXIMUM LIFE",
|
||||
"Twice Corrupted",
|
||||
"THE VAAL EMPTIED THEIR SLAVES OF BEATING HEARTS",
|
||||
"AND LEFT A MOUNTAIN OF TWITCHING DEAD",
|
||||
"Asking Price:",
|
||||
"120x Divine Orb"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "1",
|
||||
"image": "images/tooltip1.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 0,
|
||||
"y": 84
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 1185,
|
||||
"y": 774
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"image": "images/tooltip2.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 304,
|
||||
"y": 0
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 983,
|
||||
"y": 470
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"image": "images/tooltip3.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 473,
|
||||
"y": 334
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 1114,
|
||||
"y": 914
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"image": "images/tooltip4.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 209,
|
||||
"y": 264
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 1097,
|
||||
"y": 915
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"image": "images/tooltip5.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 763,
|
||||
"y": 0
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 1874,
|
||||
"y": 560
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"image": "images/tooltip6.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 1541,
|
||||
"y": 154
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 2348,
|
||||
"y": 614
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"image": "images/tooltip7.png",
|
||||
"snapshotImage": "images/tooltip-snapshot.png",
|
||||
"topLeft": {
|
||||
"x": 1921,
|
||||
"y": 40
|
||||
},
|
||||
"bottomRight": {
|
||||
"x": 2558,
|
||||
"y": 370
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetches POE2 trade API data and generates Tesseract user-words and user-patterns
|
||||
* files to improve OCR accuracy for tooltip text.
|
||||
*
|
||||
* Usage: node generate-words.mjs
|
||||
* Output: poe2.user-words, poe2.user-patterns (in same directory)
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const UA = "OAuth poe2trade/1.0 (contact: poe2trade@users.noreply.github.com)";
|
||||
|
||||
async function fetchJson(path) {
|
||||
const url = `https://www.pathofexile.com/api/trade2/data/${path}`;
|
||||
const res = await fetch(url, { headers: { "User-Agent": UA } });
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching POE2 trade API data...");
|
||||
const [items, stats, static_, filters] = await Promise.all([
|
||||
fetchJson("items"),
|
||||
fetchJson("stats"),
|
||||
fetchJson("static"),
|
||||
fetchJson("filters"),
|
||||
]);
|
||||
|
||||
const words = new Set();
|
||||
|
||||
// Helper: split text into individual words and add each
|
||||
function addWords(text) {
|
||||
if (!text) return;
|
||||
// Remove # placeholders and special chars, split on whitespace
|
||||
const cleaned = text
|
||||
.replace(/#/g, "")
|
||||
.replace(/[{}()\[\]]/g, "")
|
||||
.replace(/[+\-]/g, " ");
|
||||
for (const word of cleaned.split(/\s+/)) {
|
||||
// Only keep words that are actual words (not numbers, not single chars)
|
||||
const trimmed = word.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "");
|
||||
if (trimmed.length >= 2) words.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: add a full phrase (multi-word item name) as-is
|
||||
function addPhrase(text) {
|
||||
if (!text) return;
|
||||
addWords(text);
|
||||
}
|
||||
|
||||
// Items: type names (base types like "Tribal Mask", "Leather Vest")
|
||||
for (const cat of items.result) {
|
||||
addPhrase(cat.label);
|
||||
for (const entry of cat.entries) {
|
||||
addPhrase(entry.type);
|
||||
addPhrase(entry.name);
|
||||
addPhrase(entry.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Stats: mod text like "+#% to Chaos Resistance", "# to maximum Life"
|
||||
for (const cat of stats.result) {
|
||||
for (const entry of cat.entries) {
|
||||
addPhrase(entry.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Static: currency/fragment names like "Divine Orb", "Scroll of Wisdom"
|
||||
for (const cat of static_.result) {
|
||||
addPhrase(cat.label);
|
||||
for (const entry of cat.entries) {
|
||||
addPhrase(entry.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters: filter labels and option texts
|
||||
for (const cat of filters.result) {
|
||||
addPhrase(cat.title);
|
||||
if (cat.filters) {
|
||||
for (const f of cat.filters) {
|
||||
addPhrase(f.text);
|
||||
if (f.option?.options) {
|
||||
for (const opt of f.option.options) {
|
||||
addPhrase(opt.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add common tooltip keywords not in trade API
|
||||
const extraWords = [
|
||||
// Section headers
|
||||
"Quality", "Requires", "Level", "Asking", "Price",
|
||||
"Corrupted", "Mirrored", "Unmodifiable",
|
||||
"Twice", "Sockets",
|
||||
// Attributes
|
||||
"Strength", "Dexterity", "Intelligence", "Spirit",
|
||||
// Defense types
|
||||
"Armour", "Evasion", "Rating", "Energy", "Shield",
|
||||
// Damage types
|
||||
"Physical", "Elemental", "Lightning", "Cold", "Fire", "Chaos",
|
||||
// Common mod words
|
||||
"increased", "reduced", "more", "less",
|
||||
"added", "converted", "regeneration",
|
||||
"maximum", "minimum", "total",
|
||||
"Resistance", "Damage", "Speed", "Duration",
|
||||
"Critical", "Hit", "Chance", "Multiplier",
|
||||
"Attack", "Cast", "Spell", "Minion", "Skill",
|
||||
"Mana", "Life", "Rarity",
|
||||
// Item classes
|
||||
"Helmet", "Gloves", "Boots", "Body", "Belt",
|
||||
"Ring", "Amulet", "Shield", "Quiver",
|
||||
"Sword", "Axe", "Mace", "Dagger", "Wand", "Staff", "Bow",
|
||||
"Sceptre", "Crossbow", "Flail", "Spear",
|
||||
// Rarity
|
||||
"Normal", "Magic", "Rare", "Unique",
|
||||
];
|
||||
for (const w of extraWords) words.add(w);
|
||||
|
||||
// Sort and write user-words
|
||||
const sortedWords = [...words].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const wordsPath = join(__dirname, "poe2.user-words");
|
||||
writeFileSync(wordsPath, sortedWords.join("\n") + "\n");
|
||||
console.log(`Wrote ${sortedWords.length} words to ${wordsPath}`);
|
||||
|
||||
// Generate user-patterns for common tooltip formats
|
||||
const patterns = [
|
||||
// Stat values: "+12% to Chaos Resistance", "+3 to Level"
|
||||
"\\+\\d+%",
|
||||
"\\+\\d+",
|
||||
"\\-\\d+%",
|
||||
"\\-\\d+",
|
||||
// Ranges: "10-20"
|
||||
"\\d+-\\d+",
|
||||
// Currency amounts: "7x Divine Orb", "35x Divine Orb"
|
||||
"\\d+x",
|
||||
// Quality: "+20%"
|
||||
"\\d+%",
|
||||
// Level requirements: "Level \\d+"
|
||||
"Level \\d+",
|
||||
// Asking Price section
|
||||
"Asking Price:",
|
||||
// Item level
|
||||
"Item Level: \\d+",
|
||||
// Requires line
|
||||
"Requires:",
|
||||
// Rating values
|
||||
"Rating: \\d+",
|
||||
"Shield: \\d+",
|
||||
"Quality: \\+\\d+%",
|
||||
];
|
||||
const patternsPath = join(__dirname, "poe2.user-patterns");
|
||||
writeFileSync(patternsPath, patterns.join("\n") + "\n");
|
||||
console.log(`Wrote ${patterns.length} patterns to ${patternsPath}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 6 MiB |
|
Before Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.1 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 5.1 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
|
@ -1,14 +0,0 @@
|
|||
\+\d+%
|
||||
\+\d+
|
||||
\-\d+%
|
||||
\-\d+
|
||||
\d+-\d+
|
||||
\d+x
|
||||
\d+%
|
||||
Level \d+
|
||||
Asking Price:
|
||||
Item Level: \d+
|
||||
Requires:
|
||||
Rating: \d+
|
||||
Shield: \d+
|
||||
Quality: \+\d+%
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
Persistent Python OCR daemon (stdin/stdout JSON-per-line protocol).
|
||||
|
||||
Supports EasyOCR engine, lazy-loaded on first use.
|
||||
Managed as a subprocess by the C# OcrDaemon.
|
||||
Managed as a subprocess by PythonOcrBridge in Poe2Trade.Screen.
|
||||
|
||||
Request: {"cmd": "ocr", "engine": "easyocr", "imagePath": "C:\\temp\\screenshot.png"}
|
||||
Response: {"ok": true, "text": "...", "lines": [{"text": "...", "words": [...]}]}
|
||||
|
|
@ -12,7 +12,6 @@ import sys
|
|||
import json
|
||||
|
||||
_easyocr_reader = None
|
||||
_paddle_ocr = None
|
||||
|
||||
|
||||
def _redirect_stdout_to_stderr():
|
||||
|
|
@ -116,13 +115,6 @@ def items_to_response(items):
|
|||
return {"ok": True, "text": "\n".join(all_text_parts), "lines": lines}
|
||||
|
||||
|
||||
def run_easyocr(image_path):
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
img = np.array(Image.open(image_path))
|
||||
return run_easyocr_array(img)
|
||||
|
||||
|
||||
def run_easyocr_array(img, merge_gap=0, **easyocr_kwargs):
|
||||
reader = get_easyocr()
|
||||
|
||||
|
|
@ -147,67 +139,6 @@ def run_easyocr_array(img, merge_gap=0, **easyocr_kwargs):
|
|||
return items_to_response(items)
|
||||
|
||||
|
||||
def get_paddleocr():
|
||||
global _paddle_ocr
|
||||
if _paddle_ocr is None:
|
||||
sys.stderr.write("Loading PaddleOCR model...\n")
|
||||
sys.stderr.flush()
|
||||
import os
|
||||
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
|
||||
real_stdout = _redirect_stdout_to_stderr()
|
||||
try:
|
||||
from paddleocr import PaddleOCR
|
||||
_paddle_ocr = PaddleOCR(
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
lang="en",
|
||||
ocr_version="PP-OCRv4",
|
||||
)
|
||||
finally:
|
||||
_restore_stdout(real_stdout)
|
||||
sys.stderr.write("PaddleOCR model loaded.\n")
|
||||
sys.stderr.flush()
|
||||
return _paddle_ocr
|
||||
|
||||
|
||||
def run_paddleocr_array(img, merge_gap=0):
|
||||
ocr = get_paddleocr()
|
||||
|
||||
# Ensure RGB 3-channel
|
||||
if len(img.shape) == 2:
|
||||
import numpy as np
|
||||
img = np.stack([img, img, img], axis=-1)
|
||||
elif img.shape[2] == 4:
|
||||
img = img[:, :, :3]
|
||||
|
||||
real_stdout = _redirect_stdout_to_stderr()
|
||||
try:
|
||||
results = ocr.predict(img)
|
||||
finally:
|
||||
_restore_stdout(real_stdout)
|
||||
|
||||
items = []
|
||||
# PaddleOCR 3.4: results is list of OCRResult objects
|
||||
for res in results:
|
||||
texts = res.get("rec_texts", []) if hasattr(res, "get") else getattr(res, "rec_texts", [])
|
||||
polys = res.get("dt_polys", []) if hasattr(res, "get") else getattr(res, "dt_polys", [])
|
||||
for i, text in enumerate(texts):
|
||||
if not text.strip():
|
||||
continue
|
||||
if i < len(polys):
|
||||
bbox = polys[i]
|
||||
x, y, w, h = bbox_to_rect(bbox)
|
||||
else:
|
||||
x, y, w, h = 0, 0, 0, 0
|
||||
items.append({"text": text.strip(), "x": x, "y": y, "w": w, "h": h})
|
||||
|
||||
if merge_gap > 0:
|
||||
items = merge_nearby_detections(items, merge_gap)
|
||||
|
||||
return items_to_response(items)
|
||||
|
||||
|
||||
def load_image(req):
|
||||
"""Load image from either imagePath (file) or imageBase64 (base64-encoded PNG)."""
|
||||
from PIL import Image
|
||||
|
|
@ -232,14 +163,12 @@ def handle_request(req):
|
|||
if cmd != "ocr":
|
||||
return {"ok": False, "error": f"Unknown command: {cmd}"}
|
||||
|
||||
engine = req.get("engine", "")
|
||||
img = load_image(req)
|
||||
if img is None:
|
||||
return {"ok": False, "error": "Missing imagePath or imageBase64"}
|
||||
|
||||
merge_gap = req.get("mergeGap", 0)
|
||||
|
||||
if engine == "easyocr":
|
||||
easyocr_kwargs = {}
|
||||
for json_key, py_param in [
|
||||
("linkThreshold", "link_threshold"),
|
||||
|
|
@ -251,10 +180,6 @@ def handle_request(req):
|
|||
if json_key in req:
|
||||
easyocr_kwargs[py_param] = req[json_key]
|
||||
return run_easyocr_array(img, merge_gap=merge_gap, **easyocr_kwargs)
|
||||
elif engine == "paddleocr":
|
||||
return run_paddleocr_array(img, merge_gap=merge_gap)
|
||||
else:
|
||||
return {"ok": False, "error": f"Unknown engine: {engine}"}
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||