refactor
This commit is contained in:
parent
2d6a6bd3a1
commit
d80e723b94
28 changed files with 1801 additions and 352 deletions
|
|
@ -7,22 +7,57 @@ 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 Mat _canvas;
|
||||
private Mat _confidence; // CV_16SC1: per-pixel wall confidence counter
|
||||
private int _canvasSize;
|
||||
private MapPosition _position;
|
||||
private int _frameCount;
|
||||
private int _consecutiveMatchFails;
|
||||
private Mat? _prevWallMask; // for frame deduplication
|
||||
private readonly PathFinder _pathFinder = new();
|
||||
|
||||
public MapPosition Position => _position;
|
||||
public bool LastMatchSucceeded { get; private set; }
|
||||
public int CanvasSize => _canvasSize;
|
||||
|
||||
private const int GrowMargin = 500;
|
||||
private const int GrowAmount = 2000; // 1000px added per side
|
||||
|
||||
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);
|
||||
_canvasSize = config.CanvasSize;
|
||||
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC1, Scalar.Black);
|
||||
_confidence = new Mat(_canvasSize, _canvasSize, MatType.CV_16SC1, Scalar.Black);
|
||||
_position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0);
|
||||
}
|
||||
|
||||
private void EnsureCapacity()
|
||||
{
|
||||
var x = (int)Math.Round(_position.X);
|
||||
var y = (int)Math.Round(_position.Y);
|
||||
if (x >= GrowMargin && y >= GrowMargin &&
|
||||
x < _canvasSize - GrowMargin && y < _canvasSize - GrowMargin)
|
||||
return;
|
||||
|
||||
var oldSize = _canvasSize;
|
||||
var newSize = oldSize + GrowAmount;
|
||||
var offset = GrowAmount / 2;
|
||||
|
||||
var newCanvas = new Mat(newSize, newSize, MatType.CV_8UC1, Scalar.Black);
|
||||
var newConf = new Mat(newSize, newSize, MatType.CV_16SC1, Scalar.Black);
|
||||
using (var dst = new Mat(newCanvas, new Rect(offset, offset, oldSize, oldSize)))
|
||||
_canvas.CopyTo(dst);
|
||||
using (var dst = new Mat(newConf, new Rect(offset, offset, oldSize, oldSize)))
|
||||
_confidence.CopyTo(dst);
|
||||
|
||||
_canvas.Dispose();
|
||||
_confidence.Dispose();
|
||||
_canvas = newCanvas;
|
||||
_confidence = newConf;
|
||||
_canvasSize = newSize;
|
||||
_position = new MapPosition(_position.X + offset, _position.Y + offset);
|
||||
Log.Information("Canvas grown: {Old}→{New}, offset={Offset}", oldSize, newSize, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -31,6 +66,7 @@ public class WorldMap : IDisposable
|
|||
/// </summary>
|
||||
public MapPosition MatchAndStitch(Mat classifiedMat, Mat wallMask, MinimapMode mode = MinimapMode.Overlay)
|
||||
{
|
||||
EnsureCapacity();
|
||||
var sw = Stopwatch.StartNew();
|
||||
_frameCount++;
|
||||
|
||||
|
|
@ -44,35 +80,10 @@ public class WorldMap : IDisposable
|
|||
|
||||
var wallCountBefore = Cv2.CountNonZero(wallMask);
|
||||
|
||||
// Block-based noise filter: only needed for overlay (game effects bleed through)
|
||||
// Skip during warmup — we need walls to seed the canvas, confidence handles noise
|
||||
if (!isCorner && !needsBootstrap)
|
||||
{
|
||||
var cleanFraction = FilterNoisyBlocks(wallMask, classifiedMat);
|
||||
if (cleanFraction < 0.25)
|
||||
{
|
||||
Log.Information("Noise filter: {Clean:P0} clean, skipping ({Ms:F1}ms)",
|
||||
cleanFraction, sw.Elapsed.TotalMilliseconds);
|
||||
return _position;
|
||||
}
|
||||
}
|
||||
if (ShouldSkipFrame(classifiedMat, wallMask, isCorner, needsBootstrap, sw))
|
||||
return _position;
|
||||
|
||||
var wallCountAfter = Cv2.CountNonZero(wallMask);
|
||||
|
||||
// Frame deduplication: skip if minimap hasn't scrolled yet (but always allow warmup through)
|
||||
if (!needsBootstrap && _prevWallMask != null && _frameCount > 1)
|
||||
{
|
||||
using var xor = new Mat();
|
||||
Cv2.BitwiseXor(wallMask, _prevWallMask, xor);
|
||||
var changedPixels = Cv2.CountNonZero(xor);
|
||||
if (changedPixels < _config.FrameChangeThreshold)
|
||||
{
|
||||
Log.Debug("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
|
||||
|
|
@ -83,6 +94,7 @@ public class WorldMap : IDisposable
|
|||
if (needsBootstrap)
|
||||
{
|
||||
StitchWithConfidence(classifiedMat, _position, boosted: true, mode: mode);
|
||||
PaintExploredCircle(_position);
|
||||
if (_consecutiveMatchFails >= 30)
|
||||
{
|
||||
Log.Information("Re-bootstrap: mode={Mode} pos=({X:F1},{Y:F1}) frameSize={FS} walls={W} stitch={Ms:F1}ms",
|
||||
|
|
@ -118,6 +130,7 @@ public class WorldMap : IDisposable
|
|||
_position = matched;
|
||||
var stitchStart = sw.Elapsed.TotalMilliseconds;
|
||||
StitchWithConfidence(classifiedMat, _position, boosted: false, mode: mode);
|
||||
PaintExploredCircle(_position);
|
||||
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
|
||||
|
||||
var posDx = _position.X - prevPos.X;
|
||||
|
|
@ -127,6 +140,41 @@ public class WorldMap : IDisposable
|
|||
return _position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Noise filter + frame deduplication. Returns true if the frame should be skipped.
|
||||
/// </summary>
|
||||
private bool ShouldSkipFrame(Mat classifiedMat, Mat wallMask, bool isCorner, bool needsBootstrap, Stopwatch sw)
|
||||
{
|
||||
// Block-based noise filter: only needed for overlay (game effects bleed through)
|
||||
// Skip during warmup — we need walls to seed the canvas, confidence handles noise
|
||||
if (!isCorner && !needsBootstrap)
|
||||
{
|
||||
var cleanFraction = FilterNoisyBlocks(wallMask, classifiedMat);
|
||||
if (cleanFraction < 0.25)
|
||||
{
|
||||
Log.Information("Noise filter: {Clean:P0} clean, skipping ({Ms:F1}ms)",
|
||||
cleanFraction, sw.Elapsed.TotalMilliseconds);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Frame deduplication: skip if minimap hasn't scrolled yet (but always allow warmup through)
|
||||
if (!needsBootstrap && _prevWallMask != null && _frameCount > 1)
|
||||
{
|
||||
using var xor = new Mat();
|
||||
Cv2.BitwiseXor(wallMask, _prevWallMask, xor);
|
||||
var changedPixels = Cv2.CountNonZero(xor);
|
||||
if (changedPixels < _config.FrameChangeThreshold)
|
||||
{
|
||||
Log.Debug("Frame dedup: {Changed} changed pixels, skipping ({Ms:F1}ms)",
|
||||
changedPixels, sw.Elapsed.TotalMilliseconds);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private MapPosition? MatchPosition(Mat wallMask, MapPosition estimate)
|
||||
{
|
||||
var frameSize = wallMask.Width;
|
||||
|
|
@ -143,8 +191,8 @@ public class WorldMap : IDisposable
|
|||
// 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 sx1 = Math.Min(_canvasSize, sx + searchSize);
|
||||
var sy1 = Math.Min(_canvasSize, sy + searchSize);
|
||||
var sw = sx1 - sx0;
|
||||
var sh = sy1 - sy0;
|
||||
|
||||
|
|
@ -213,8 +261,8 @@ public class WorldMap : IDisposable
|
|||
var srcY = Math.Max(0, -canvasY);
|
||||
var dstX = Math.Max(0, canvasX);
|
||||
var dstY = Math.Max(0, canvasY);
|
||||
var w = Math.Min(frameSize - srcX, _config.CanvasSize - dstX);
|
||||
var h = Math.Min(frameSize - srcY, _config.CanvasSize - dstY);
|
||||
var w = Math.Min(frameSize - srcX, _canvasSize - dstX);
|
||||
var h = Math.Min(frameSize - srcY, _canvasSize - dstY);
|
||||
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
|
|
@ -300,16 +348,23 @@ public class WorldMap : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Mark explored area: circle around player, overwrite Unknown and Fog
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark explored area: circle around player position, overwrite Unknown and Fog cells.
|
||||
/// Called by MatchAndStitch after stitch in both bootstrap and match-success branches.
|
||||
/// </summary>
|
||||
private void PaintExploredCircle(MapPosition position)
|
||||
{
|
||||
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 y1 = Math.Min(_canvasSize - 1, pcy + r);
|
||||
var x0 = Math.Max(0, pcx - r);
|
||||
var x1 = Math.Min(_config.CanvasSize - 1, pcx + r);
|
||||
var x1 = Math.Min(_canvasSize - 1, pcx + r);
|
||||
|
||||
for (var y = y0; y <= y1; y++)
|
||||
for (var x = x0; x <= x1; x++)
|
||||
|
|
@ -363,140 +418,8 @@ public class WorldMap : IDisposable
|
|||
return totalBlocks > 0 ? (double)cleanBlocks / totalBlocks : 1.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BFS through walkable (Explored) cells to find the best frontier direction.
|
||||
/// Instead of stopping at the nearest frontier, runs the full BFS and counts
|
||||
/// frontier cells reachable per first-step direction. Prefers directions with
|
||||
/// more frontier cells (corridors) over directions with few (dead-end rooms).
|
||||
/// </summary>
|
||||
public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 400)
|
||||
{
|
||||
var cx = (int)Math.Round(pos.X);
|
||||
var cy = (int)Math.Round(pos.Y);
|
||||
|
||||
// BFS at half resolution for speed (step=2 → ~200x200 effective grid for r=400)
|
||||
const int step = 2;
|
||||
var size = _config.CanvasSize;
|
||||
var rr = searchRadius / step;
|
||||
var gridW = 2 * rr + 1;
|
||||
|
||||
var visited = new bool[gridW * gridW];
|
||||
// Propagate first step from start during BFS (avoids per-frontier trace-back)
|
||||
var firstStepX = new short[gridW * gridW];
|
||||
var firstStepY = new short[gridW * gridW];
|
||||
|
||||
var queue = new Queue<(int gx, int gy)>(4096);
|
||||
var startGx = rr;
|
||||
var startGy = rr;
|
||||
visited[startGy * gridW + startGx] = true;
|
||||
queue.Enqueue((startGx, startGy));
|
||||
|
||||
// 8-connected neighbors
|
||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
||||
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
||||
|
||||
// Count frontier cells per first-step direction
|
||||
var frontierCounts = new Dictionary<int, int>();
|
||||
var firstStepCoords = new Dictionary<int, (short gx, short gy)>();
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (gx, gy) = queue.Dequeue();
|
||||
|
||||
// Map grid coords back to canvas coords
|
||||
var wx = cx + (gx - rr) * step;
|
||||
var wy = cy + (gy - rr) * step;
|
||||
|
||||
// Check if this explored cell borders Unknown/Fog (= frontier)
|
||||
if (gx != startGx || gy != startGy)
|
||||
{
|
||||
var cellIdx = gy * gridW + gx;
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var nx = wx + dxs[d] * step;
|
||||
var ny = wy + dys[d] * step;
|
||||
if (nx < 0 || nx >= size || ny < 0 || ny >= size) continue;
|
||||
var neighbor = _canvas.At<byte>(ny, nx);
|
||||
if (neighbor == (byte)MapCell.Unknown || neighbor == (byte)MapCell.Fog)
|
||||
{
|
||||
var fsKey = firstStepY[cellIdx] * gridW + firstStepX[cellIdx];
|
||||
if (frontierCounts.TryGetValue(fsKey, out var cnt))
|
||||
frontierCounts[fsKey] = cnt + 1;
|
||||
else
|
||||
{
|
||||
frontierCounts[fsKey] = 1;
|
||||
firstStepCoords[fsKey] = (firstStepX[cellIdx], firstStepY[cellIdx]);
|
||||
}
|
||||
break; // don't double-count this cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand to walkable neighbors
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var ngx = gx + dxs[d];
|
||||
var ngy = gy + dys[d];
|
||||
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
|
||||
|
||||
var idx = ngy * gridW + ngx;
|
||||
if (visited[idx]) continue;
|
||||
|
||||
var nwx = cx + (ngx - rr) * step;
|
||||
var nwy = cy + (ngy - rr) * step;
|
||||
if (nwx < 0 || nwx >= size || nwy < 0 || nwy >= size) continue;
|
||||
|
||||
var cell = _canvas.At<byte>(nwy, nwx);
|
||||
if (cell != (byte)MapCell.Explored) continue;
|
||||
|
||||
visited[idx] = true;
|
||||
// Propagate first step: direct neighbors of start ARE the first step
|
||||
if (gx == startGx && gy == startGy)
|
||||
{
|
||||
firstStepX[idx] = (short)ngx;
|
||||
firstStepY[idx] = (short)ngy;
|
||||
}
|
||||
else
|
||||
{
|
||||
var parentIdx = gy * gridW + gx;
|
||||
firstStepX[idx] = firstStepX[parentIdx];
|
||||
firstStepY[idx] = firstStepY[parentIdx];
|
||||
}
|
||||
queue.Enqueue((ngx, ngy));
|
||||
}
|
||||
}
|
||||
|
||||
if (frontierCounts.Count == 0)
|
||||
{
|
||||
Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pick direction with the most frontier cells (prefers corridors over dead ends)
|
||||
var bestKey = -1;
|
||||
var bestCount = 0;
|
||||
foreach (var (key, count) in frontierCounts)
|
||||
{
|
||||
if (count > bestCount)
|
||||
{
|
||||
bestCount = count;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
var (bestGx, bestGy) = firstStepCoords[bestKey];
|
||||
var dirX = (double)(bestGx - startGx);
|
||||
var dirY = (double)(bestGy - startGy);
|
||||
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (len < 0.001) return (1, 0);
|
||||
|
||||
dirX /= len;
|
||||
dirY /= len;
|
||||
|
||||
Log.Debug("BFS: {DirCount} directions, best={Best} frontier cells, dir=({Dx:F2},{Dy:F2})",
|
||||
frontierCounts.Count, bestCount, dirX, dirY);
|
||||
return (dirX, dirY);
|
||||
}
|
||||
=> _pathFinder.FindNearestUnexplored(_canvas, _canvasSize, pos, searchRadius);
|
||||
|
||||
public byte[] GetMapSnapshot()
|
||||
{
|
||||
|
|
@ -510,8 +433,8 @@ public class WorldMap : IDisposable
|
|||
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 x0 = Math.Clamp(cx - half, 0, _canvasSize - viewSize);
|
||||
var y0 = Math.Clamp(cy - half, 0, _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));
|
||||
|
|
@ -538,11 +461,14 @@ public class WorldMap : IDisposable
|
|||
|
||||
public void Reset()
|
||||
{
|
||||
_canvas.SetTo(Scalar.Black);
|
||||
_confidence.SetTo(Scalar.Black);
|
||||
_canvas.Dispose();
|
||||
_confidence.Dispose();
|
||||
_canvasSize = _config.CanvasSize;
|
||||
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC1, Scalar.Black);
|
||||
_confidence = new Mat(_canvasSize, _canvasSize, MatType.CV_16SC1, Scalar.Black);
|
||||
_prevWallMask?.Dispose();
|
||||
_prevWallMask = null;
|
||||
_position = new MapPosition(_config.CanvasSize / 2.0, _config.CanvasSize / 2.0);
|
||||
_position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0);
|
||||
_frameCount = 0;
|
||||
_consecutiveMatchFails = 0;
|
||||
}
|
||||
|
|
@ -565,8 +491,8 @@ public class WorldMap : IDisposable
|
|||
var cy = (int)Math.Round(_position.Y);
|
||||
var x0 = Math.Max(0, cx - halfClear);
|
||||
var y0 = Math.Max(0, cy - halfClear);
|
||||
var w = Math.Min(_config.CanvasSize, cx + halfClear) - x0;
|
||||
var h = Math.Min(_config.CanvasSize, cy + halfClear) - y0;
|
||||
var w = Math.Min(_canvasSize, cx + halfClear) - x0;
|
||||
var h = Math.Min(_canvasSize, cy + halfClear) - y0;
|
||||
if (w > 0 && h > 0)
|
||||
{
|
||||
var rect = new Rect(x0, y0, w, h);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue