poe2-bot/src/Poe2Trade.Navigation/WorldMap.cs
2026-02-13 15:07:03 -05:00

404 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
public class WorldMap : IDisposable
{
private readonly MinimapConfig _config;
private readonly Mat _canvas;
private readonly Mat _confidence; // CV_16SC1: per-pixel wall confidence counter
private MapPosition _position;
private int _frameCount;
private int _consecutiveMatchFails;
private Mat? _prevWallMask; // for frame deduplication
public MapPosition Position => _position;
public bool LastMatchSucceeded { get; private set; }
public WorldMap(MinimapConfig config)
{
_config = config;
_canvas = new Mat(config.CanvasSize, config.CanvasSize, MatType.CV_8UC1, Scalar.Black);
_confidence = new Mat(config.CanvasSize, config.CanvasSize, MatType.CV_16SC1, Scalar.Black);
_position = new MapPosition(config.CanvasSize / 2.0, config.CanvasSize / 2.0);
}
/// <summary>
/// Match current wall mask against the accumulated map to find position,
/// then stitch walls and paint explored area.
/// </summary>
public MapPosition MatchAndStitch(Mat classifiedMat, Mat wallMask)
{
var sw = Stopwatch.StartNew();
_frameCount++;
var needsBootstrap = _frameCount <= _config.WarmupFrames || _consecutiveMatchFails >= 30;
// Block-based noise filter: zero out 50×50 blocks with >25% wall density
// Removes localized glow (waypoints, effects) while preserving real walls
var cleanFraction = FilterNoisyBlocks(wallMask, classifiedMat);
if (cleanFraction < 0.25 && !needsBootstrap)
{
Log.Information("Noise filter: {Clean:P0} clean, skipping ({Ms:F1}ms)",
cleanFraction, sw.Elapsed.TotalMilliseconds);
return _position;
}
// Frame deduplication: skip if minimap hasn't scrolled yet
if (_prevWallMask != null && _frameCount > 1)
{
using var xor = new Mat();
Cv2.BitwiseXor(wallMask, _prevWallMask, xor);
var changedPixels = Cv2.CountNonZero(xor);
if (changedPixels < _config.FrameChangeThreshold)
{
Log.Information("Frame dedup: {Changed} changed pixels, skipping ({Ms:F1}ms)",
changedPixels, sw.Elapsed.TotalMilliseconds);
return _position;
}
}
var dedupMs = sw.Elapsed.TotalMilliseconds;
// Store current wall mask for next frame's dedup check
_prevWallMask?.Dispose();
_prevWallMask = wallMask.Clone();
// Warmup / re-bootstrap: stitch at current position to seed the canvas
if (needsBootstrap)
{
StitchWithConfidence(classifiedMat, _position, boosted: true);
if (_consecutiveMatchFails >= 30)
{
Log.Information("Re-bootstrap: stitching at current position after {Fails} match failures ({Ms:F1}ms)",
_consecutiveMatchFails, sw.Elapsed.TotalMilliseconds);
_consecutiveMatchFails = 0;
}
else
{
Log.Information("Warmup frame {N}/{Total}: stitch={Ms:F1}ms",
_frameCount, _config.WarmupFrames, sw.Elapsed.TotalMilliseconds);
}
return _position;
}
// Match wallMask against canvas to find best position
var matchStart = sw.Elapsed.TotalMilliseconds;
var matched = MatchPosition(wallMask, _position);
var matchMs = sw.Elapsed.TotalMilliseconds - matchStart;
if (matched == null)
{
_consecutiveMatchFails++;
LastMatchSucceeded = false;
Log.Information("MatchAndStitch: dedup={Dedup:F1}ms match={Match:F1}ms (FAILED x{Fails}) total={Total:F1}ms",
dedupMs, matchMs, _consecutiveMatchFails, sw.Elapsed.TotalMilliseconds);
return _position; // don't stitch — wrong position would corrupt the canvas
}
_consecutiveMatchFails = 0;
LastMatchSucceeded = true;
_position = matched;
var stitchStart = sw.Elapsed.TotalMilliseconds;
StitchWithConfidence(classifiedMat, _position, boosted: false);
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
Log.Information("MatchAndStitch: dedup={Dedup:F1}ms match={Match:F1}ms stitch={Stitch:F1}ms total={Total:F1}ms",
dedupMs, matchMs, stitchMs, sw.Elapsed.TotalMilliseconds);
return _position;
}
private MapPosition? MatchPosition(Mat wallMask, MapPosition estimate)
{
var frameSize = wallMask.Width;
var searchPad = _config.MatchSearchRadius;
var searchSize = frameSize + 2 * searchPad;
var cx = (int)Math.Round(estimate.X);
var cy = (int)Math.Round(estimate.Y);
// Search region on canvas (centered on estimate)
var sx = cx - searchSize / 2;
var sy = cy - searchSize / 2;
// Clamp to canvas bounds
var sx0 = Math.Max(0, sx);
var sy0 = Math.Max(0, sy);
var sx1 = Math.Min(_config.CanvasSize, sx + searchSize);
var sy1 = Math.Min(_config.CanvasSize, sy + searchSize);
var sw = sx1 - sx0;
var sh = sy1 - sy0;
// Search region must be larger than template
if (sw <= frameSize || sh <= frameSize)
return null;
// Extract search ROI and convert to binary wall mask
using var searchRoi = new Mat(_canvas, new Rect(sx0, sy0, sw, sh));
using var canvasWalls = new Mat();
Cv2.Compare(searchRoi, new Scalar((byte)MapCell.Wall), canvasWalls, CmpType.EQ);
// Check if canvas has enough walls to match against
var canvasWallCount = Cv2.CountNonZero(canvasWalls);
var frameWallCount = Cv2.CountNonZero(wallMask);
if (canvasWallCount < 50)
{
Log.Information("Match fail: too few canvas walls ({CanvasWalls}) frame walls={FrameWalls}",
canvasWallCount, frameWallCount);
return null;
}
// Template match: find where frame's walls best align with canvas walls
using var result = new Mat();
Cv2.MatchTemplate(canvasWalls, wallMask, result, TemplateMatchModes.CCoeffNormed);
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal < _config.MatchConfidence)
{
Log.Information("Match fail: low confidence {Conf:F3} (need {Min:F2}) canvas={CanvasWalls} frame={FrameWalls}",
maxVal, _config.MatchConfidence, canvasWallCount, frameWallCount);
return null;
}
// Convert match position to world coordinates
// maxLoc is the top-left of the template in the search ROI
var matchX = sx0 + maxLoc.X + frameSize / 2.0;
var matchY = sy0 + maxLoc.Y + frameSize / 2.0;
Log.Debug("Map match: ({X:F1}, {Y:F1}) conf={Conf:F3} canvas={CanvasWalls} frame={FrameWalls}",
matchX, matchY, maxVal, canvasWallCount, frameWallCount);
return new MapPosition(matchX, matchY);
}
private void StitchWithConfidence(Mat classifiedMat, MapPosition position, bool boosted)
{
var halfSize = _config.CaptureSize / 2;
var canvasX = (int)Math.Round(position.X) - halfSize;
var canvasY = (int)Math.Round(position.Y) - halfSize;
// Clamp to canvas bounds
var srcX = Math.Max(0, -canvasX);
var srcY = Math.Max(0, -canvasY);
var dstX = Math.Max(0, canvasX);
var dstY = Math.Max(0, canvasY);
var w = Math.Min(_config.CaptureSize - srcX, _config.CanvasSize - dstX);
var h = Math.Min(_config.CaptureSize - srcY, _config.CanvasSize - dstY);
if (w <= 0 || h <= 0) return;
var srcRect = new Rect(srcX, srcY, w, h);
var dstRect = new Rect(dstX, dstY, w, h);
var srcRoi = new Mat(classifiedMat, srcRect);
var dstRoi = new Mat(_canvas, dstRect);
var confRoi = new Mat(_confidence, dstRect);
var confInc = (short)_config.ConfidenceInc;
var confDec = (short)_config.ConfidenceDec;
var confThreshold = (short)_config.ConfidenceThreshold;
var confMax = (short)_config.ConfidenceMax;
// Wall pixels: increase confidence. Non-wall pixels in visible area: decay confidence.
// Real walls accumulate high confidence (40) and survive brief non-confirmation during
// movement. Transient noise (waypoint glow, effects) only reaches moderate confidence
// and gets removed as it decays.
for (var row = 0; row < h; row++)
for (var col = 0; col < w; col++)
{
var srcVal = srcRoi.At<byte>(row, col);
var conf = confRoi.At<short>(row, col);
if (srcVal == (byte)MapCell.Wall)
{
conf = boosted
? confMax
: Math.Min((short)(conf + confInc), confMax);
}
else if (conf > 0)
{
// Visible area, not a wall → slow decay
conf = Math.Max((short)(conf - confDec), (short)0);
}
else
{
continue; // nothing to update
}
confRoi.Set(row, col, conf);
if (conf >= confThreshold)
dstRoi.Set(row, col, (byte)MapCell.Wall);
else if (dstRoi.At<byte>(row, col) == (byte)MapCell.Wall)
dstRoi.Set(row, col, (byte)MapCell.Explored); // lost confidence → demote
}
// Mark explored area: circle around player, only overwrite Unknown
var pcx = (int)Math.Round(position.X);
var pcy = (int)Math.Round(position.Y);
var r = _config.ExploredRadius;
var r2 = r * r;
var y0 = Math.Max(0, pcy - r);
var y1 = Math.Min(_config.CanvasSize - 1, pcy + r);
var x0 = Math.Max(0, pcx - r);
var x1 = Math.Min(_config.CanvasSize - 1, pcx + r);
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
{
var dx = x - pcx;
var dy = y - pcy;
if (dx * dx + dy * dy > r2) continue;
if (_canvas.At<byte>(y, x) == (byte)MapCell.Unknown)
_canvas.Set(y, x, (byte)MapCell.Explored);
}
}
/// <summary>
/// Zero out 50×50 blocks where wall density exceeds 25%.
/// Modifies wallMask and classifiedMat in-place.
/// Returns fraction of blocks that are clean (0.01.0).
/// </summary>
private static double FilterNoisyBlocks(Mat wallMask, Mat classifiedMat,
int blockSize = 50, double blockMaxDensity = 0.25)
{
var rows = wallMask.Rows;
var cols = wallMask.Cols;
var totalBlocks = 0;
var cleanBlocks = 0;
for (var by = 0; by < rows; by += blockSize)
for (var bx = 0; bx < cols; bx += blockSize)
{
var bw = Math.Min(blockSize, cols - bx);
var bh = Math.Min(blockSize, rows - by);
totalBlocks++;
var blockRect = new Rect(bx, by, bw, bh);
using var blockRoi = new Mat(wallMask, blockRect);
var wallCount = Cv2.CountNonZero(blockRoi);
if ((double)wallCount / (bw * bh) > blockMaxDensity)
{
// Zero out this noisy block in both mats
blockRoi.SetTo(Scalar.Black);
using var classBlock = new Mat(classifiedMat, blockRect);
classBlock.SetTo(Scalar.Black);
}
else
{
cleanBlocks++;
}
}
return totalBlocks > 0 ? (double)cleanBlocks / totalBlocks : 1.0;
}
public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 200)
{
var cx = (int)Math.Round(pos.X);
var cy = (int)Math.Round(pos.Y);
var bestAngle = double.NaN;
var bestScore = 0;
const int sectorCount = 16;
var fogRadius = _config.CaptureSize / 2;
for (var sector = 0; sector < sectorCount; sector++)
{
var angle = 2 * Math.PI * sector / sectorCount;
var score = 0;
for (var r = fogRadius - 20; r <= fogRadius + searchRadius; r += 5)
{
for (var spread = -15; spread <= 15; spread += 5)
{
var sampleAngle = angle + spread * Math.PI / 180;
var sx = cx + (int)(r * Math.Cos(sampleAngle));
var sy = cy + (int)(r * Math.Sin(sampleAngle));
if (sx < 0 || sx >= _config.CanvasSize || sy < 0 || sy >= _config.CanvasSize)
continue;
if (_canvas.At<byte>(sy, sx) == (byte)MapCell.Unknown)
score++;
}
}
if (score > bestScore)
{
bestScore = score;
bestAngle = angle;
}
}
if (bestScore == 0 || double.IsNaN(bestAngle))
{
Log.Information("No unexplored area found within search radius");
return null;
}
var dirX = Math.Cos(bestAngle);
var dirY = Math.Sin(bestAngle);
Log.Debug("Best exploration direction: angle={Angle:F1}deg score={Score}",
bestAngle * 180 / Math.PI, bestScore);
return (dirX, dirY);
}
public byte[] GetMapSnapshot()
{
Cv2.ImEncode(".png", _canvas, out var buf);
return buf;
}
public byte[] GetViewportSnapshot(MapPosition center, int viewSize = 400)
{
var cx = (int)Math.Round(center.X);
var cy = (int)Math.Round(center.Y);
var half = viewSize / 2;
var x0 = Math.Clamp(cx - half, 0, _config.CanvasSize - viewSize);
var y0 = Math.Clamp(cy - half, 0, _config.CanvasSize - viewSize);
var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, viewSize));
using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13));
for (var r = 0; r < viewSize; r++)
for (var c = 0; c < viewSize; c++)
{
var v = roi.At<byte>(r, c);
if (v == (byte)MapCell.Explored)
colored.Set(r, c, new Vec3b(104, 64, 31));
else if (v == (byte)MapCell.Wall)
colored.Set(r, c, new Vec3b(26, 45, 61));
}
var px = cx - x0;
var py = cy - y0;
if (px >= 0 && px < viewSize && py >= 0 && py < viewSize)
Cv2.Circle(colored, new Point(px, py), 4, new Scalar(0, 140, 255), -1);
Cv2.ImEncode(".png", colored, out var buf);
return buf;
}
public void Reset()
{
_canvas.SetTo(Scalar.Black);
_confidence.SetTo(Scalar.Black);
_prevWallMask?.Dispose();
_prevWallMask = null;
_position = new MapPosition(_config.CanvasSize / 2.0, _config.CanvasSize / 2.0);
_frameCount = 0;
_consecutiveMatchFails = 0;
}
public void Dispose()
{
_canvas.Dispose();
_confidence.Dispose();
_prevWallMask?.Dispose();
}
}