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); } /// /// Match current wall mask against the accumulated map to find position, /// then stitch walls and paint explored area. /// 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(row, col); var conf = confRoi.At(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(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(y, x) == (byte)MapCell.Unknown) _canvas.Set(y, x, (byte)MapCell.Explored); } } /// /// 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). /// 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(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(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(); } }