switched to new way

This commit is contained in:
Boki 2026-02-13 01:12:51 -05:00
parent f22d182c8f
commit 4a65c8e17b
96 changed files with 4991 additions and 10025 deletions

20
.gitignore vendored
View file

@ -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
View 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

View file

@ -1 +0,0 @@
{"cmd":"crop-test","engine":"diff"}

View file

@ -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}]}

View file

2888
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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);
}

View 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>

View 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;
}
}

View 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);
}
}

View 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();
}
}

View 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;
}
}

View 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/?$", "");
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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>

View 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
}

View 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);
}

View 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);
}

View 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);
}

View 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>

View 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);
}

View 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);
}
}

View 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 });
}
}

View 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>

View 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);
// }
}

View 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>

View 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();
}

View file

@ -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; }
}

View 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);
}
}

View file

@ -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);
}

View file

@ -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))
{

View 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;
}
}

View file

@ -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++)

View 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,
};
}
}

View 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>

View file

@ -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; }

View file

@ -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)

View 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);
}
}

View file

@ -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);

View file

@ -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,

View 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>

View 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\"]";
}

View 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; }
}
}

View 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>

View 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();
}
}

View 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();
}

View 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>

View 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();
}

View 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}"; }
}
}

View 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));
}
}

View 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;
}

View 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 &amp; 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>

View 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);
}
}

View 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>

View file

@ -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();
}
}

View file

@ -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,
};
}
}

View file

@ -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; }
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -1 +0,0 @@
return OcrDaemon.Daemon.Run();

View file

@ -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; } = [];
}
}

View file

@ -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"
]
}
]

View file

@ -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
}
}
]

View file

@ -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);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 MiB

View file

@ -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+%

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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,29 +163,23 @@ 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"),
("textThreshold", "text_threshold"),
("lowText", "low_text"),
("widthThs", "width_ths"),
("paragraph", "paragraph"),
]:
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}"}
easyocr_kwargs = {}
for json_key, py_param in [
("linkThreshold", "link_threshold"),
("textThreshold", "text_threshold"),
("lowText", "low_text"),
("widthThs", "width_ths"),
("paragraph", "paragraph"),
]:
if json_key in req:
easyocr_kwargs[py_param] = req[json_key]
return run_easyocr_array(img, merge_gap=merge_gap, **easyocr_kwargs)
def main():

View file

@ -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"]
}