404 lines
15 KiB
C#
404 lines
15 KiB
C#
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.0–1.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();
|
||
}
|
||
}
|