This commit is contained in:
Boki 2026-02-28 15:13:22 -05:00
parent bef61f841d
commit c3de5fdb63
107 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,352 @@
using System.Diagnostics;
using OpenCvSharp;
using Poe2Trade.Core;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Navigation;
public record AtlasProgress(int TilesCaptured, int Row, string Phase);
/// <summary>
/// Stitches atlas frames into a panorama canvas.
/// Implements IFrameConsumer so it receives shared pipeline frames — no separate DXGI needed.
/// </summary>
public class AtlasPanorama : IFrameConsumer, IDisposable
{
// Canvas — CV_8UC3 BGR, growable
private Mat _canvas;
private int _canvasSize;
private int _viewX, _viewY; // current viewport position on canvas
private int _tilesCaptured;
private bool _firstFrame = true;
// HUD-free capture region at 2560x1440
// Top 150px: header + act tabs
// Bottom 200px: skill bar + globes
// Sides 300px: globe frames + padding
private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
// Canvas management
private const int InitialCanvasSize = 10000;
private const int GrowMargin = 500;
private const int GrowAmount = 4000;
// Template matching — done at reduced resolution for speed
private const int MatchScale = 4; // downscale factor for template matching
private const int TemplateSize = 200; // at full res, becomes 50px at match scale
private const int SearchMargin = 300; // max scroll shift between frames at ~30fps
private const double MatchThreshold = 0.70;
// Movement detection: minimum viewport shift (px) to count as a new tile
private const int MinViewportShift = 20;
// Perspective correction: the atlas camera looks from the south, so the top of the
// screen is further away and horizontally compressed.
// Measured: bottom edge ≈ 24cm, top edge ≈ 17cm on 2560x1440
// Default 0.15 — use CalibratePerspective to find the optimal value.
private readonly float _perspectiveFactor;
private Mat? _warpMatrix;
public int TilesCaptured => _tilesCaptured;
public event Action<AtlasProgress>? ProgressUpdated;
private void ReportProgress(string phase)
{
ProgressUpdated?.Invoke(new AtlasProgress(_tilesCaptured, 0, phase));
}
public AtlasPanorama(float perspectiveFactor = 0.115f)
{
_perspectiveFactor = perspectiveFactor;
_canvasSize = InitialCanvasSize;
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC3, Scalar.Black);
_viewX = _canvasSize / 2;
_viewY = _canvasSize / 2;
}
/// <summary>
/// IFrameConsumer — called by the pipeline with each shared screen frame.
/// Crops the atlas region and stitches it onto the canvas.
/// </summary>
public void Process(ScreenFrame frame)
{
var sw = Stopwatch.StartNew();
using var bgr = frame.CropBgr(CaptureRegion);
using var corrected = CorrectPerspective(bgr);
if (_firstFrame)
{
PasteFrame(corrected, _viewX - corrected.Width / 2, _viewY - corrected.Height / 2);
_tilesCaptured++;
_firstFrame = false;
ReportProgress("Capturing");
Log.Debug("AtlasPanorama: first frame pasted in {Ms:F1}ms", sw.Elapsed.TotalMilliseconds);
return;
}
StitchFrame(corrected);
if (sw.ElapsedMilliseconds > 50)
Log.Warning("AtlasPanorama: Process took {Ms}ms", sw.ElapsedMilliseconds);
}
/// <summary>
/// Warp the frame from the tilted atlas camera view to a top-down projection.
/// The top of the screen is further from the camera and appears narrower —
/// we stretch it back to equal width.
/// </summary>
private Mat CorrectPerspective(Mat frame)
{
var w = frame.Width;
var h = frame.Height;
var inset = (int)(w * _perspectiveFactor);
// Compute warp matrix once (all frames are the same size)
if (_warpMatrix == null)
{
// Source: trapezoid as seen on screen (top edge is narrower)
var src = new Point2f[]
{
new(inset, 0), // top-left (shifted inward)
new(w - inset, 0), // top-right (shifted inward)
new(w, h), // bottom-right (full width, close to camera)
new(0, h), // bottom-left
};
// Destination: rectangle (top-down)
var dst = new Point2f[]
{
new(0, 0),
new(w, 0),
new(w, h),
new(0, h),
};
_warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
Log.Information("AtlasPanorama: perspective matrix computed (factor={F}, inset={I}px)",
_perspectiveFactor, inset);
}
var result = new Mat();
Cv2.WarpPerspective(frame, result, _warpMatrix, new Size(w, h));
return result;
}
/// <summary>
/// Wait until cancelled. Stitching happens via Process() on the pipeline thread.
/// </summary>
public async Task Run(CancellationToken ct)
{
Log.Information("AtlasPanorama: started (pipeline consumer)");
_tilesCaptured = 0;
_firstFrame = true;
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException) { }
Log.Information("AtlasPanorama: stopped — {Tiles} tiles", _tilesCaptured);
}
/// <summary>
/// Return a PNG of the full canvas built so far, downscaled to fit maxDim.
/// </summary>
public byte[]? GetViewportSnapshot(int maxDim = 900)
{
if (_tilesCaptured == 0) return null;
using var trimmed = TrimCanvas();
if (trimmed.Empty()) return null;
// Downscale to fit within maxDim, preserving aspect ratio
var scale = Math.Min((double)maxDim / trimmed.Width, (double)maxDim / trimmed.Height);
if (scale >= 1.0)
{
Cv2.ImEncode(".png", trimmed, out var buf);
return buf;
}
using var small = new Mat();
Cv2.Resize(trimmed, small, new Size(
(int)(trimmed.Width * scale),
(int)(trimmed.Height * scale)));
Cv2.ImEncode(".png", small, out var buf2);
return buf2;
}
/// <summary>
/// Encode the trimmed canvas as PNG bytes.
/// </summary>
public byte[] GetResultPng()
{
using var trimmed = TrimCanvas();
Cv2.ImEncode(".png", trimmed, out var buf);
return buf;
}
/// <summary>
/// Save the trimmed canvas to a file.
/// </summary>
public void SaveResult(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
using var trimmed = TrimCanvas();
Cv2.ImWrite(path, trimmed);
Log.Information("AtlasPanorama: saved to {Path} ({W}x{H})", path, trimmed.Width, trimmed.Height);
}
/// <summary>
/// Template-match at reduced resolution, then paste at full resolution.
/// </summary>
private void StitchFrame(Mat frame)
{
EnsureCapacity();
// Search region at full res
var halfW = frame.Width / 2 + SearchMargin;
var halfH = frame.Height / 2 + SearchMargin;
var sx0 = Math.Max(0, _viewX - halfW);
var sy0 = Math.Max(0, _viewY - halfH);
var sx1 = Math.Min(_canvasSize, _viewX + halfW);
var sy1 = Math.Min(_canvasSize, _viewY + halfH);
var sW = sx1 - sx0;
var sH = sy1 - sy0;
// Template: center strip at full res
var tW = Math.Min(TemplateSize, frame.Width);
var tH = Math.Min(TemplateSize, frame.Height);
var tX = (frame.Width - tW) / 2;
var tY = (frame.Height - tH) / 2;
if (sW <= tW || sH <= tH)
{
Log.Debug("AtlasPanorama: search region too small, pasting at viewport");
PasteFrame(frame, _viewX - frame.Width / 2, _viewY - frame.Height / 2);
_tilesCaptured++;
return;
}
// Downscale template + search region for fast matching
using var templateFull = new Mat(frame, new Rect(tX, tY, tW, tH));
using var templateSmall = new Mat();
Cv2.Resize(templateFull, templateSmall, new Size(tW / MatchScale, tH / MatchScale));
using var searchRoi = new Mat(_canvas, new Rect(sx0, sy0, sW, sH));
using var searchSmall = new Mat();
Cv2.Resize(searchRoi, searchSmall, new Size(sW / MatchScale, sH / MatchScale));
using var result = new Mat();
Cv2.MatchTemplate(searchSmall, templateSmall, result, TemplateMatchModes.CCoeffNormed);
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal >= MatchThreshold)
{
// Scale match position back to full res
var frameCanvasX = sx0 + maxLoc.X * MatchScale - tX;
var frameCanvasY = sy0 + maxLoc.Y * MatchScale - tY;
var newVx = frameCanvasX + frame.Width / 2;
var newVy = frameCanvasY + frame.Height / 2;
var dx = Math.Abs(newVx - _viewX);
var dy = Math.Abs(newVy - _viewY);
// Only paste when the viewport actually moved — prevents cumulative
// drift from the 4x downscale quantization on still frames
if (dx >= MinViewportShift || dy >= MinViewportShift)
{
PasteFrame(frame, frameCanvasX, frameCanvasY);
_viewX = newVx;
_viewY = newVy;
_tilesCaptured++;
ReportProgress("Capturing");
Log.Information("AtlasPanorama: tile {N} at ({X},{Y}) shift=({Dx},{Dy}) conf={Conf:F3}",
_tilesCaptured, frameCanvasX, frameCanvasY, dx, dy, maxVal);
}
}
else
{
Log.Information("AtlasPanorama: match failed (conf={Conf:F3}), skipping frame", maxVal);
}
}
private void PasteFrame(Mat frame, int canvasX, int canvasY)
{
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(frame.Width - srcX, _canvasSize - dstX);
var h = Math.Min(frame.Height - srcY, _canvasSize - dstY);
if (w <= 0 || h <= 0) return;
using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
using var dstRoi = new Mat(_canvas, new Rect(dstX, dstY, w, h));
srcRoi.CopyTo(dstRoi);
}
private void EnsureCapacity()
{
if (_viewX >= GrowMargin && _viewY >= GrowMargin &&
_viewX < _canvasSize - GrowMargin && _viewY < _canvasSize - GrowMargin)
return;
var oldSize = _canvasSize;
var newSize = oldSize + GrowAmount;
var offset = GrowAmount / 2;
var newCanvas = new Mat(newSize, newSize, MatType.CV_8UC3, Scalar.Black);
using (var dst = new Mat(newCanvas, new Rect(offset, offset, oldSize, oldSize)))
_canvas.CopyTo(dst);
_canvas.Dispose();
_canvas = newCanvas;
_canvasSize = newSize;
_viewX += offset;
_viewY += offset;
Log.Information("AtlasPanorama: canvas grown {Old}x{Old} -> {New}x{New}", oldSize, oldSize, newSize, newSize);
}
private Mat TrimCanvas()
{
using var gray = new Mat();
Cv2.CvtColor(_canvas, gray, ColorConversionCodes.BGR2GRAY);
using var mask = new Mat();
Cv2.Threshold(gray, mask, 1, 255, ThresholdTypes.Binary);
var points = new Mat();
Cv2.FindNonZero(mask, points);
if (points.Empty())
{
Log.Warning("AtlasPanorama: canvas is empty after trim");
return _canvas.Clone();
}
var bbox = Cv2.BoundingRect(points);
points.Dispose();
const int pad = 10;
var x = Math.Max(0, bbox.X - pad);
var y = Math.Max(0, bbox.Y - pad);
var w = Math.Min(_canvasSize - x, bbox.Width + 2 * pad);
var h = Math.Min(_canvasSize - y, bbox.Height + 2 * pad);
return new Mat(_canvas, new Rect(x, y, w, h)).Clone();
}
public void Dispose()
{
_canvas.Dispose();
_warpMatrix?.Dispose();
}
}

View file

@ -0,0 +1,128 @@
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
/// <summary>
/// Detects minimap icons (doors, checkpoints) via template matching.
/// Loads RGBA templates once, converts to grayscale for matching.
/// </summary>
internal class IconDetector : IDisposable
{
private readonly Mat _doorTemplate;
private readonly Mat _checkpointOffTemplate;
private readonly Mat _checkpointOnTemplate;
private const double DoorThreshold = 0.65;
private const double CheckpointThreshold = 0.75;
public IconDetector(string assetsDir)
{
_doorTemplate = LoadGray(Path.Combine(assetsDir, "door.png"));
_checkpointOffTemplate = LoadGray(Path.Combine(assetsDir, "checkpoint-off.png"));
_checkpointOnTemplate = LoadGray(Path.Combine(assetsDir, "checkpoint-on.png"));
Log.Information("IconDetector loaded: door={DW}x{DH} cpOff={OW}x{OH} cpOn={NW}x{NH}",
_doorTemplate.Width, _doorTemplate.Height,
_checkpointOffTemplate.Width, _checkpointOffTemplate.Height,
_checkpointOnTemplate.Width, _checkpointOnTemplate.Height);
}
private static Mat LoadGray(string path)
{
var bgra = Cv2.ImRead(path, ImreadModes.Unchanged);
if (bgra.Empty())
throw new FileNotFoundException($"Icon template not found: {path}");
var gray = new Mat();
if (bgra.Channels() == 4)
{
using var bgr = new Mat();
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY);
}
else if (bgra.Channels() == 3)
{
Cv2.CvtColor(bgra, gray, ColorConversionCodes.BGR2GRAY);
}
else
{
gray = bgra.Clone();
}
bgra.Dispose();
return gray;
}
/// <summary>
/// Detect all door icons in the frame. Returns center points in frame coords.
/// </summary>
public List<Point> DetectDoors(Mat grayFrame)
=> Detect(grayFrame, _doorTemplate, DoorThreshold);
/// <summary>
/// Detect all inactive checkpoint icons. Returns center points in frame coords.
/// </summary>
public List<Point> DetectCheckpointsOff(Mat grayFrame)
=> Detect(grayFrame, _checkpointOffTemplate, CheckpointThreshold);
/// <summary>
/// Detect all active checkpoint icons. Returns center points in frame coords.
/// </summary>
public List<Point> DetectCheckpointsOn(Mat grayFrame)
=> Detect(grayFrame, _checkpointOnTemplate, CheckpointThreshold);
/// <summary>
/// Greedy multi-match: find best match, record center, zero out neighborhood, repeat.
/// </summary>
private static List<Point> Detect(Mat grayFrame, Mat template, double threshold)
{
var results = new List<Point>();
if (grayFrame.Width < template.Width || grayFrame.Height < template.Height)
return results;
using var result = new Mat();
Cv2.MatchTemplate(grayFrame, template, result, TemplateMatchModes.CCoeffNormed);
var tw = template.Width;
var th = template.Height;
while (true)
{
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal < threshold)
break;
// Record center point of the matched region
var centerX = maxLoc.X + tw / 2;
var centerY = maxLoc.Y + th / 2;
results.Add(new Point(centerX, centerY));
// Zero out neighborhood to prevent re-detection
var zeroX = Math.Max(0, maxLoc.X - tw / 2);
var zeroY = Math.Max(0, maxLoc.Y - th / 2);
var zeroW = Math.Min(tw * 2, result.Width - zeroX);
var zeroH = Math.Min(th * 2, result.Height - zeroY);
if (zeroW > 0 && zeroH > 0)
{
using var roi = new Mat(result, new Rect(zeroX, zeroY, zeroW, zeroH));
roi.SetTo(Scalar.Black);
}
}
return results;
}
/// <summary>
/// Get the template size for doors (used to stamp wall regions).
/// </summary>
public Size DoorSize => new(_doorTemplate.Width, _doorTemplate.Height);
public void Dispose()
{
_doorTemplate.Dispose();
_checkpointOffTemplate.Dispose();
_checkpointOnTemplate.Dispose();
}
}

View file

@ -0,0 +1,446 @@
using OpenCvSharp;
using Poe2Trade.Screen;
using Serilog;
using Region = Poe2Trade.Core.Region;
using Size = OpenCvSharp.Size;
namespace Poe2Trade.Navigation;
public class MinimapCapture : IFrameConsumer, IDisposable
{
private readonly MinimapConfig _config;
private readonly WallColorTracker _colorTracker;
private readonly IScreenCapture _backend; // kept for debug capture paths
private readonly IconDetector? _iconDetector;
private int _modeCheckCounter = 9; // trigger mode detection on first frame
private MinimapMode _detectedMode = MinimapMode.Overlay;
private int _pendingModeCount; // consecutive detections of a different mode
private MinimapMode _pendingMode;
private MinimapFrame? _lastFrame;
public MinimapMode DetectedMode => _detectedMode;
public MinimapFrame? LastFrame => _lastFrame;
/// <summary>
/// Atomically take ownership of the last frame (sets _lastFrame to null).
/// Caller is responsible for disposing the returned frame.
/// </summary>
public MinimapFrame? TakeFrame() => Interlocked.Exchange(ref _lastFrame, null);
public event Action<MinimapMode>? ModeChanged;
public MinimapCapture(MinimapConfig config, IScreenCapture backend, string? assetsDir = null)
{
_config = config;
_colorTracker = new WallColorTracker(config.WallLoHSV, config.WallHiHSV);
_backend = backend;
// Load icon templates if assets directory provided and contains templates
if (assetsDir != null && File.Exists(Path.Combine(assetsDir, "door.png")))
{
try { _iconDetector = new IconDetector(assetsDir); }
catch (Exception ex) { Log.Warning(ex, "Failed to load icon templates, icon detection disabled"); }
}
}
/// <summary>
/// IFrameConsumer entry point — called by FramePipeline with the shared screen frame.
/// </summary>
public void Process(ScreenFrame screen)
{
// Auto-detect minimap mode by checking orange player dot at both positions.
// null = not in gameplay (loading/menu) → skip frame entirely.
// Require 3 consecutive detections of a new mode to avoid transient flips.
if (++_modeCheckCounter >= 10)
{
_modeCheckCounter = 0;
var detected = DetectMinimapMode(screen);
if (detected == null)
{
_pendingModeCount = 0;
_lastFrame = null;
return; // not in gameplay — skip frame
}
if (detected != _detectedMode)
{
if (detected == _pendingMode)
_pendingModeCount++;
else
{
_pendingMode = detected.Value;
_pendingModeCount = 1;
}
if (_pendingModeCount >= 3)
{
var oldMode = _detectedMode;
var oldRegion = _detectedMode == MinimapMode.Overlay ? _config.OverlayRegion : _config.CornerRegion;
_detectedMode = detected.Value;
var newRegion = _detectedMode == MinimapMode.Overlay ? _config.OverlayRegion : _config.CornerRegion;
Log.Information("MODE SWITCH: {Old} → {New} | oldRegion=({OX},{OY},{OW}x{OH}) newRegion=({NX},{NY},{NW}x{NH})",
oldMode, _detectedMode,
oldRegion.X, oldRegion.Y, oldRegion.Width, oldRegion.Height,
newRegion.X, newRegion.Y, newRegion.Width, newRegion.Height);
ResetAdaptation();
_pendingModeCount = 0;
ModeChanged?.Invoke(_detectedMode);
}
}
else
{
_pendingModeCount = 0;
}
}
var region = _detectedMode == MinimapMode.Overlay
? _config.OverlayRegion
: _config.CornerRegion;
using var bgr = screen.CropBgr(region);
if (bgr.Empty())
return;
var frame = ProcessBgr(bgr);
if (frame == null) return;
Log.Debug("Process: mode={Mode} cropSize={W}x{H} classifiedSize={CW}x{CH} wallSize={WW}x{WH}",
_detectedMode, bgr.Width, bgr.Height,
frame.ClassifiedMat.Width, frame.ClassifiedMat.Height,
frame.WallMask.Width, frame.WallMask.Height);
var old = Interlocked.Exchange(ref _lastFrame, frame);
old?.Dispose();
}
private MinimapFrame? ProcessBgr(Mat bgr)
{
using var hsv = new Mat();
Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV);
// Player mask (orange marker)
using var playerMask = new Mat();
Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask);
var playerOffset = FindCentroid(playerMask);
// Wall mask: target #A2AEE5 blue-lavender structure lines (range adapts per-map)
var wallMask = BuildWallMask(hsv, playerMask, sample: true);
// Build classified mat (use actual frame size — differs between overlay and corner)
var frameSize = bgr.Width;
var classified = new Mat(frameSize, frameSize, MatType.CV_8UC1, Scalar.Black);
{
using var fogMask = BuildFogMask(hsv, wallMask, playerMask);
classified.SetTo(new Scalar((byte)MapCell.Fog), fogMask);
classified.SetTo(new Scalar((byte)MapCell.Wall), wallMask);
}
// Icon detection (overlay mode only — corner has different scale)
List<Point>? checkpointsOff = null;
List<Point>? checkpointsOn = null;
if (_detectedMode == MinimapMode.Overlay && _iconDetector != null)
{
using var grayForIcons = new Mat();
Cv2.CvtColor(bgr, grayForIcons, ColorConversionCodes.BGR2GRAY);
// Doors: stamp as Wall in classified + wallMask (fills gap so BFS blocks doors)
var doors = _iconDetector.DetectDoors(grayForIcons);
if (doors.Count > 0)
{
var doorSize = _iconDetector.DoorSize;
const int pad = 2;
foreach (var d in doors)
{
var rx = Math.Max(0, d.X - doorSize.Width / 2 - pad);
var ry = Math.Max(0, d.Y - doorSize.Height / 2 - pad);
var rw = Math.Min(doorSize.Width + 2 * pad, frameSize - rx);
var rh = Math.Min(doorSize.Height + 2 * pad, frameSize - ry);
if (rw <= 0 || rh <= 0) continue;
var rect = new Rect(rx, ry, rw, rh);
using var classRoi = new Mat(classified, rect);
classRoi.SetTo(new Scalar((byte)MapCell.Wall));
using var wallRoi = new Mat(wallMask, rect);
wallRoi.SetTo(new Scalar(255));
}
Log.Debug("Icons: {Count} doors stamped as wall", doors.Count);
}
// Checkpoints: don't stamp — just pass positions into MinimapFrame
checkpointsOff = _iconDetector.DetectCheckpointsOff(grayForIcons);
checkpointsOn = _iconDetector.DetectCheckpointsOn(grayForIcons);
if (checkpointsOff.Count > 0 || checkpointsOn.Count > 0)
Log.Debug("Icons: {Off} checkpoints-off, {On} checkpoints-on", checkpointsOff.Count, checkpointsOn.Count);
}
// Gray for correlation tracking (player zeroed)
var grayForCorr = new Mat();
Cv2.CvtColor(bgr, grayForCorr, ColorConversionCodes.BGR2GRAY);
grayForCorr.SetTo(Scalar.Black, playerMask);
// Corner mode: rescale classified + wall mask to match overlay scale
if (_detectedMode == MinimapMode.Corner && Math.Abs(_config.CornerScale - 1.0) > 0.01)
{
var scaledSize = (int)Math.Round(frameSize * _config.CornerScale);
var scaledClassified = new Mat();
Cv2.Resize(classified, scaledClassified, new Size(scaledSize, scaledSize),
interpolation: InterpolationFlags.Nearest);
classified.Dispose();
classified = scaledClassified;
var scaledWalls = new Mat();
Cv2.Resize(wallMask, scaledWalls, new Size(scaledSize, scaledSize),
interpolation: InterpolationFlags.Nearest);
wallMask.Dispose();
wallMask = scaledWalls;
var scaledGray = new Mat();
Cv2.Resize(grayForCorr, scaledGray, new Size(scaledSize, scaledSize),
interpolation: InterpolationFlags.Linear);
grayForCorr.Dispose();
grayForCorr = scaledGray;
}
return new MinimapFrame(
GrayMat: grayForCorr,
WallMask: wallMask,
ClassifiedMat: classified,
PlayerOffset: playerOffset,
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
CheckpointsOff: checkpointsOff,
CheckpointsOn: checkpointsOn
);
}
/// <summary>
/// Detect minimap mode by sampling the orange player dot (#DE581B) at both
/// the overlay center and corner center. Returns null if neither is found
/// (loading screen, menu, map transition).
/// </summary>
private MinimapMode? DetectMinimapMode(ScreenFrame screen)
{
if (IsOrangeDot(screen, _config.OverlayCenterX, _config.OverlayCenterY))
return MinimapMode.Overlay;
if (IsOrangeDot(screen, _config.CornerCenterX, _config.CornerCenterY))
return MinimapMode.Corner;
return null;
}
private static bool IsOrangeDot(ScreenFrame screen, int cx, int cy)
{
if (cx < 2 || cy < 2 || cx + 2 >= screen.Width || cy + 2 >= screen.Height)
return false;
double bSum = 0, gSum = 0, rSum = 0;
var count = 0;
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++)
{
var px = screen.PixelAt(cx + dx, cy + dy);
bSum += px.Item0;
gSum += px.Item1;
rSum += px.Item2;
count++;
}
var r = rSum / count;
var g = gSum / count;
var b = bSum / count;
// #DE581B → R=222, G=88, B=27
const int tol = 60;
return Math.Abs(r - 222) < tol && Math.Abs(g - 88) < tol && Math.Abs(b - 27) < tol;
}
private Mat BuildWallMask(Mat hsv, Mat playerMask, bool sample = false)
{
var isCorner = _detectedMode == MinimapMode.Corner;
var lo = isCorner ? _config.CornerWallLoHSV : (_colorTracker.AdaptedLo ?? _config.WallLoHSV);
var hi = isCorner ? _config.CornerWallHiHSV : (_colorTracker.AdaptedHi ?? _config.WallHiHSV);
var wallMask = new Mat();
Cv2.InRange(hsv, lo, hi, wallMask);
using var notPlayer = new Mat();
Cv2.BitwiseNot(playerMask, notPlayer);
Cv2.BitwiseAnd(wallMask, notPlayer, wallMask);
// Overlay mode: exclude game text labels bleeding through the minimap.
// Text color #E0E0F6 → HSV(120, 23, 246): same blue hue as walls but
// much lower saturation. AA edges blend with the background pushing S
// above the wall floor. Detect the text core, dilate to cover the AA
// fringe, then subtract from wall mask before morphology amplifies it.
if (!isCorner)
{
using var textMask = new Mat();
Cv2.InRange(hsv, new Scalar(0, 0, 200), new Scalar(180, 50, 255), textMask);
using var textKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(11, 11));
Cv2.Dilate(textMask, textMask, textKernel);
using var notText = new Mat();
Cv2.BitwiseNot(textMask, notText);
Cv2.BitwiseAnd(wallMask, notText, wallMask);
}
if (sample && !isCorner)
_colorTracker.SampleFrame(hsv, wallMask);
using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3));
Cv2.MorphologyEx(wallMask, wallMask, MorphTypes.Close, kernel); // fill 1-2px gaps
Cv2.Dilate(wallMask, wallMask, kernel);
FilterSmallComponents(wallMask, _config.WallMinArea);
return wallMask;
}
private Mat BuildFogMask(Mat hsv, Mat wallMask, Mat playerMask)
{
using var allBlue = new Mat();
Cv2.InRange(hsv, _config.FogLoHSV, _config.FogHiHSV, allBlue);
using var notPlayer = new Mat();
Cv2.BitwiseNot(playerMask, notPlayer);
Cv2.BitwiseAnd(allBlue, notPlayer, allBlue);
var fogMask = new Mat();
using var notWalls = new Mat();
Cv2.BitwiseNot(wallMask, notWalls);
Cv2.BitwiseAnd(allBlue, notWalls, fogMask);
FilterSmallComponents(fogMask, _config.FogMinArea);
return fogMask;
}
private static void FilterSmallComponents(Mat mask, int minArea)
{
if (minArea <= 0) return;
using var labels = new Mat();
using var stats = new Mat();
using var centroids = new Mat();
var numLabels = Cv2.ConnectedComponentsWithStats(mask, labels, stats, centroids);
if (numLabels <= 1) return;
mask.SetTo(Scalar.Black);
for (var i = 1; i < numLabels; i++)
{
var area = stats.At<int>(i, 4);
if (area < minArea) continue;
using var comp = new Mat();
Cv2.Compare(labels, new Scalar(i), comp, CmpType.EQ);
mask.SetTo(new Scalar(255), comp);
}
}
/// <summary>
/// Capture a single frame and return the requested pipeline stage as PNG bytes.
/// Uses CaptureRegion directly (debug path, not the pipeline).
/// </summary>
public byte[]? CaptureStage(MinimapDebugStage stage)
{
var region = _detectedMode == MinimapMode.Overlay
? _config.OverlayRegion
: _config.CornerRegion;
using var bgr = _backend.CaptureRegion(region);
if (bgr == null || bgr.Empty()) return null;
if (stage == MinimapDebugStage.Raw) return EncodePng(bgr);
using var hsv = new Mat();
Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV);
if (stage is MinimapDebugStage.Hue or MinimapDebugStage.Saturation or MinimapDebugStage.Value)
{
var channels = Cv2.Split(hsv);
var idx = stage switch { MinimapDebugStage.Hue => 0, MinimapDebugStage.Saturation => 1, _ => 2 };
var result = EncodePng(channels[idx]);
foreach (var c in channels) c.Dispose();
return result;
}
using var playerMask = new Mat();
Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask);
if (stage == MinimapDebugStage.Player) return EncodePng(playerMask);
using var wallMask = BuildWallMask(hsv, playerMask);
if (stage == MinimapDebugStage.Walls) return EncodePng(wallMask);
using var fogMask = BuildFogMask(hsv, wallMask, playerMask);
if (stage == MinimapDebugStage.Fog) return EncodePng(fogMask);
using var classified = new Mat(bgr.Height, bgr.Width, MatType.CV_8UC3, Scalar.Black);
classified.SetTo(new Scalar(180, 140, 70), fogMask);
classified.SetTo(new Scalar(26, 45, 61), wallMask);
classified.SetTo(new Scalar(0, 165, 255), playerMask);
return EncodePng(classified);
}
private static byte[] EncodePng(Mat mat)
{
Cv2.ImEncode(".png", mat, out var buf);
return buf;
}
/// <summary>
/// Save debug images for tuning thresholds.
/// </summary>
public void SaveDebugCapture(string dir = "debug-minimap")
{
Directory.CreateDirectory(dir);
var region = _detectedMode == MinimapMode.Overlay
? _config.OverlayRegion
: _config.CornerRegion;
using var bgr = _backend.CaptureRegion(region);
if (bgr == null || bgr.Empty()) return;
using var hsv = new Mat();
Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV);
using var playerMask = new Mat();
Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask);
using var wallMask = BuildWallMask(hsv, playerMask);
using var classified = new Mat(bgr.Height, bgr.Width, MatType.CV_8UC3, Scalar.Black);
classified.SetTo(new Scalar(26, 45, 61), wallMask);
classified.SetTo(new Scalar(0, 165, 255), playerMask);
Cv2.ImWrite(Path.Combine(dir, "01-raw.png"), bgr);
Cv2.ImWrite(Path.Combine(dir, "02-walls.png"), wallMask);
Cv2.ImWrite(Path.Combine(dir, "03-player.png"), playerMask);
Cv2.ImWrite(Path.Combine(dir, "04-classified.png"), classified);
var channels = Cv2.Split(hsv);
Cv2.ImWrite(Path.Combine(dir, "05-hue.png"), channels[0]);
Cv2.ImWrite(Path.Combine(dir, "06-sat.png"), channels[1]);
Cv2.ImWrite(Path.Combine(dir, "07-val.png"), channels[2]);
foreach (var c in channels) c.Dispose();
Log.Information("Debug minimap images saved to {Dir}", Path.GetFullPath(dir));
}
private Point2d FindCentroid(Mat mask)
{
var moments = Cv2.Moments(mask, true);
if (moments.M00 < 10)
return new Point2d(0, 0);
var cx = moments.M10 / moments.M00 - mask.Width / 2.0;
var cy = moments.M01 / moments.M00 - mask.Height / 2.0;
return new Point2d(cx, cy);
}
public void CommitWallColors() => _colorTracker.Commit();
public void ResetAdaptation() => _colorTracker.Reset();
public void Dispose()
{
_lastFrame?.Dispose();
_iconDetector?.Dispose();
}
}

View file

@ -0,0 +1,561 @@
using System.Diagnostics;
using OpenCvSharp;
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Navigation;
public class NavigationExecutor : IDisposable
{
private readonly IGameController _game;
private readonly MinimapConfig _config;
private readonly FramePipeline _pipeline;
private readonly MinimapCapture _capture;
private readonly WorldMap _worldMap;
private readonly StuckDetector _stuck;
private readonly EnemyDetector? _enemyDetector;
private NavigationState _state = NavigationState.Idle;
private volatile bool _stopped;
private volatile byte[]? _cachedViewport;
private static readonly Random Rng = new();
// Thread-safe direction passing (capture loop writes, input loop reads)
private readonly object _dirLock = new();
private double _desiredDirX, _desiredDirY;
private bool _directionChanged;
// Path following state (only touched by capture loop thread)
private List<Point>? _currentPath;
private int _pathIndex;
private long _lastPathTime;
// Checkpoint collection
private Point? _checkpointGoal;
// Valid state transitions (from → allowed targets)
private static readonly Dictionary<NavigationState, NavigationState[]> ValidTransitions = new()
{
[NavigationState.Idle] = [NavigationState.Capturing],
[NavigationState.Capturing] = [NavigationState.Processing, NavigationState.Idle],
[NavigationState.Processing] = [NavigationState.Planning, NavigationState.Moving, NavigationState.Stuck, NavigationState.Capturing, NavigationState.Failed, NavigationState.Idle],
[NavigationState.Planning] = [NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
[NavigationState.Moving] = [NavigationState.Capturing, NavigationState.Idle],
[NavigationState.Stuck] = [NavigationState.Planning, NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
[NavigationState.Completed] = [NavigationState.Idle],
[NavigationState.Failed] = [NavigationState.Capturing, NavigationState.Idle],
};
public event Action<NavigationState>? StateChanged;
public event Action<byte[]>? ViewportUpdated;
public NavigationState State => _state;
public NavigationExecutor(IGameController game, FramePipeline pipeline,
MinimapCapture capture, MinimapConfig? config = null, EnemyDetector? enemyDetector = null)
{
_game = game;
_config = config ?? new MinimapConfig();
_stuck = new StuckDetector(_config.StuckThreshold, _config.StuckFrameCount);
_pipeline = pipeline;
_capture = capture;
_enemyDetector = enemyDetector;
_worldMap = new WorldMap(_config);
_capture.ModeChanged += _ =>
{
_worldMap.Rebootstrap();
_stuck.Reset();
};
}
private void SetState(NavigationState s)
{
var from = _state;
if (from == s) return;
// Validate transition (Stop() can set Idle from any state — always allowed)
if (s != NavigationState.Idle &&
ValidTransitions.TryGetValue(from, out var allowed) &&
!allowed.Contains(s))
{
Log.Warning("Invalid state transition: {From} → {To}", from, s);
}
Log.Debug("State: {From} → {To}", from, s);
_state = s;
StateChanged?.Invoke(s);
}
public Task Stop()
{
_stopped = true;
SetState(NavigationState.Idle);
Log.Information("Navigation executor stopped");
return Task.CompletedTask;
}
public void Reset()
{
_worldMap.Reset();
_capture.ResetAdaptation();
_stopped = false;
_stuck.Reset();
_currentPath = null;
_pathIndex = 0;
_lastPathTime = 0;
_checkpointGoal = null;
SetState(NavigationState.Idle);
Log.Information("Navigation reset (new area)");
}
public async Task RunExploreLoop()
{
_stopped = false;
lock (_dirLock) _directionChanged = false;
Log.Information("Starting explore loop");
_cachedViewport = _worldMap.GetViewportSnapshot(_worldMap.Position);
// Input loop runs concurrently — handles WASD keys + combat clicks
var inputTask = RunInputLoop();
try
{
while (!_stopped)
{
var frameStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
try
{
// 1. Capture + process via pipeline (single full-screen capture)
SetState(NavigationState.Capturing);
await _pipeline.ProcessOneFrame();
using var frame = _capture.TakeFrame();
if (frame == null)
{
Log.Warning("Failed to capture minimap frame");
await Helpers.Sleep(_config.CaptureIntervalMs);
continue;
}
SetState(NavigationState.Processing);
var mode = _capture.DetectedMode;
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, mode,
frame.CheckpointsOff, frame.CheckpointsOn);
if (_worldMap.LastMatchSucceeded)
{
_capture.CommitWallColors();
// Only re-render viewport when canvas was modified (avoids ~3ms PNG encode on dedup-skips)
_cachedViewport = _worldMap.GetViewportSnapshot(pos);
ViewportUpdated?.Invoke(_cachedViewport);
}
// Stuck detection
_stuck.Update(pos);
// 2. Checkpoint goal check: if we're near the goal, clear it
if (_checkpointGoal is { } goal)
{
var gdx = goal.X - pos.X;
var gdy = goal.Y - pos.Y;
if (gdx * gdx + gdy * gdy < 20 * 20)
{
Log.Information("Checkpoint reached at ({X},{Y})", goal.X, goal.Y);
_worldMap.RemoveCheckpointOff(goal);
_checkpointGoal = null;
_currentPath = null; // force re-path
}
}
// 3. Re-path when needed (path consumed, stuck, or stale)
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (NeedsRepath(now))
{
if (TryRepath(pos, now))
{
SetState(NavigationState.Completed);
break;
}
}
// 4. Follow path → if endpoint reached, immediately repath for seamless movement
var dir = FollowPath(pos);
if (dir == null)
{
_currentPath = null; // force NeedsRepath on retry
now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (TryRepath(pos, now))
{
SetState(NavigationState.Completed);
break;
}
dir = FollowPath(pos);
}
if (dir != null)
{
SetState(NavigationState.Moving);
lock (_dirLock)
{
_desiredDirX = dir.Value.dirX;
_desiredDirY = dir.Value.dirY;
_directionChanged = true;
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in explore loop");
SetState(NavigationState.Failed);
await Helpers.Sleep(1000);
continue;
}
// Sleep remainder of frame interval
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - frameStart;
var sleepMs = _config.CaptureIntervalMs - (int)elapsed;
if (sleepMs > 0)
await Helpers.Sleep(sleepMs);
}
}
finally
{
_stopped = true; // signal input loop to exit
_cachedViewport = null;
await inputTask; // wait for input loop to release keys
}
if (_state != NavigationState.Completed)
SetState(NavigationState.Idle);
Log.Information("Explore loop ended");
}
/// <summary>
/// Runs concurrently with the capture loop. Owns all game input:
/// WASD key holds (from direction posted by capture loop) and periodic combat clicks.
/// </summary>
private async Task RunInputLoop()
{
var heldKeys = new HashSet<int>();
var nextCombatTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + 1000 + Rng.Next(1000);
try
{
while (!_stopped)
{
// Apply direction changes from capture loop (thread-safe read)
bool changed;
double dirX, dirY;
lock (_dirLock)
{
changed = _directionChanged;
dirX = _desiredDirX;
dirY = _desiredDirY;
_directionChanged = false;
}
if (changed)
await UpdateWasdKeys(heldKeys, dirX, dirY);
// Combat clicks on timer — prefer detected enemies, fall back to random
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now >= nextCombatTime)
{
nextCombatTime = now + 1000 + Rng.Next(1000);
var target = GetBestTarget();
if (target != null)
{
await _game.LeftClickAt(target.Cx, target.Cy);
await Helpers.Sleep(100 + Rng.Next(100));
await _game.RightClickAt(target.Cx, target.Cy);
}
else
{
var cx = _config.ScreenCenterX + Rng.Next(-150, 150);
var cy = _config.ScreenCenterY + Rng.Next(-150, 150);
await _game.LeftClickAt(cx, cy);
await Helpers.Sleep(100 + Rng.Next(100));
cx = _config.ScreenCenterX + Rng.Next(-150, 150);
cy = _config.ScreenCenterY + Rng.Next(-150, 150);
await _game.RightClickAt(cx, cy);
}
}
await Helpers.Sleep(15);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in input loop");
}
finally
{
// Always release held keys
foreach (var key in heldKeys)
await _game.KeyUp(key);
}
}
/// <summary>
/// Convert a direction vector to WASD key holds. Releases keys no longer needed,
/// presses new ones. Supports diagonal movement (two keys at once).
/// </summary>
private async Task UpdateWasdKeys(HashSet<int> held, double dirX, double dirY)
{
var wanted = new HashSet<int>();
// Threshold for diagonal: if both components are significant, hold both keys
const double threshold = 0.3;
if (dirY < -threshold) wanted.Add(InputSender.VK.W); // up
if (dirY > threshold) wanted.Add(InputSender.VK.S); // down
if (dirX < -threshold) wanted.Add(InputSender.VK.A); // left
if (dirX > threshold) wanted.Add(InputSender.VK.D); // right
// If direction is too weak, default to W
if (wanted.Count == 0) wanted.Add(InputSender.VK.W);
// Release keys no longer wanted
foreach (var key in held.Except(wanted).ToList())
{
await _game.KeyUp(key);
held.Remove(key);
}
// Press newly wanted keys
foreach (var key in wanted.Except(held).ToList())
{
await _game.KeyDown(key);
held.Add(key);
}
}
/// <summary>
/// Compute a new path (checkpoint → nearby checkpoint → frontier).
/// Returns true if map is fully explored (no more frontiers).
/// </summary>
private bool TryRepath(MapPosition pos, long nowMs)
{
if (_stuck.IsStuck)
SetState(NavigationState.Stuck);
else
SetState(NavigationState.Planning);
(double dirX, double dirY)? direction = null;
if (_checkpointGoal is { } cpGoal)
{
direction = _worldMap.FindPathToTarget(pos, cpGoal);
if (direction == null)
{
Log.Information("Checkpoint unreachable, clearing goal");
_checkpointGoal = null;
}
}
if (_checkpointGoal == null)
{
var nearCp = _worldMap.GetNearestCheckpointOff(pos);
if (nearCp != null)
{
_checkpointGoal = nearCp.Value;
direction = _worldMap.FindPathToTarget(pos, nearCp.Value);
if (direction != null)
Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y);
else
_checkpointGoal = null;
}
}
if (direction == null)
{
direction = _worldMap.FindNearestUnexplored(pos);
if (direction == null)
{
Log.Information("Map fully explored");
return true;
}
}
_currentPath = _worldMap.LastBfsPath;
_pathIndex = 0;
_lastPathTime = nowMs;
if (_stuck.IsStuck)
_stuck.Reset();
return false;
}
/// <summary>
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
/// </summary>
private bool NeedsRepath(long nowMs)
{
if (_currentPath == null || _pathIndex >= _currentPath.Count) return true;
if (_stuck.IsStuck) return true;
if (nowMs - _lastPathTime >= 3000) return true;
return false;
}
/// <summary>
/// Follow the current BFS path: advance past passed waypoints, then compute
/// direction toward a look-ahead point ~30px ahead on the path.
/// </summary>
private (double dirX, double dirY)? FollowPath(MapPosition pos)
{
if (_currentPath == null || _pathIndex >= _currentPath.Count)
return null;
var px = pos.X;
var py = pos.Y;
// Advance past waypoints the player has already passed (within 15px)
while (_pathIndex < _currentPath.Count - 1)
{
var wp = _currentPath[_pathIndex];
var dx = wp.X - px;
var dy = wp.Y - py;
if (dx * dx + dy * dy > 15 * 15) break;
_pathIndex++;
}
// Find look-ahead target: walk forward until >= 30px from player
var target = _currentPath[^1]; // default to path end
for (var i = _pathIndex; i < _currentPath.Count; i++)
{
var wp = _currentPath[i];
var dx = wp.X - px;
var dy = wp.Y - py;
if (dx * dx + dy * dy >= 30 * 30)
{
target = wp;
break;
}
}
var tdx = target.X - px;
var tdy = target.Y - py;
var len = Math.Sqrt(tdx * tdx + tdy * tdy);
if (len < 1)
{
_pathIndex = _currentPath.Count; // mark consumed so NeedsRepath triggers
return null;
}
return (tdx / len, tdy / len);
}
/// <summary>
/// Pick the best enemy target from detection snapshot.
/// Prefers health-bar-confirmed enemies, then closest to screen center.
/// </summary>
private DetectedEnemy? GetBestTarget()
{
if (_enemyDetector == null) return null;
var snapshot = _enemyDetector.Latest;
if (snapshot.Enemies.Count == 0) return null;
var screenCx = _config.ScreenCenterX;
var screenCy = _config.ScreenCenterY;
return snapshot.Enemies
.OrderByDescending(e => e.HealthBarConfirmed)
.ThenBy(e => Math.Abs(e.Cx - screenCx) + Math.Abs(e.Cy - screenCy))
.FirstOrDefault();
}
private async Task ClickToMove(double dirX, double dirY)
{
// Player is at minimap center on screen; click offset from center
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
if (len < 0.001) return;
var nx = dirX / len;
var ny = dirY / len;
var clickX = _config.MinimapCenterX + (int)(nx * _config.ClickRadius);
var clickY = _config.MinimapCenterY + (int)(ny * _config.ClickRadius);
Log.Debug("Click to move: ({X}, {Y}) dir=({Dx:F2}, {Dy:F2})", clickX, clickY, nx, ny);
await _game.LeftClickAt(clickX, clickY);
}
private async Task ClickRandomDirection()
{
var angle = Rng.NextDouble() * 2 * Math.PI;
await ClickToMove(Math.Cos(angle), Math.Sin(angle));
}
public bool IsExploring => _state != NavigationState.Idle && _state != NavigationState.Completed && _state != NavigationState.Failed;
public MapPosition Position => _worldMap.Position;
public MapPosition WorldPosition => _worldMap.WorldPosition;
public bool LastMatchSucceeded => _worldMap.LastMatchSucceeded;
public bool Frozen { get => _worldMap.Frozen; set => _worldMap.Frozen = value; }
public byte[] GetMapSnapshot() => _worldMap.GetMapSnapshot();
public byte[] GetViewportSnapshot(int viewSize = 400)
{
var cached = _cachedViewport;
if (cached != null) return cached;
return _worldMap.GetViewportSnapshot(_worldMap.Position, viewSize);
}
/// <summary>
/// Capture one frame via pipeline, track position, stitch into world map.
/// Returns PNG bytes for the requested debug stage (or world map viewport by default).
/// </summary>
public async Task<byte[]?> ProcessFrame(MinimapDebugStage stage = MinimapDebugStage.WorldMap)
{
var sw = Stopwatch.StartNew();
var captureStart = sw.Elapsed.TotalMilliseconds;
try
{
await _pipeline.ProcessOneFrame();
}
catch (Exception ex)
{
Log.Warning(ex, "Pipeline capture failed");
}
using var frame = _capture.TakeFrame();
var captureMs = sw.Elapsed.TotalMilliseconds - captureStart;
if (frame == null)
{
// Pipeline didn't produce a frame — fall back to direct capture for debug stages
if (stage != MinimapDebugStage.WorldMap)
return _capture.CaptureStage(stage);
return null;
}
var stitchStart = sw.Elapsed.TotalMilliseconds;
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, _capture.DetectedMode,
frame.CheckpointsOff, frame.CheckpointsOn);
if (_worldMap.LastMatchSucceeded)
_capture.CommitWallColors();
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
var renderStart = sw.Elapsed.TotalMilliseconds;
byte[]? result;
if (stage == MinimapDebugStage.WorldMap)
result = _worldMap.GetViewportSnapshot(pos);
else
result = _capture.CaptureStage(stage);
var renderMs = sw.Elapsed.TotalMilliseconds - renderStart;
Log.Debug("ProcessFrame: capture={Capture:F1}ms stitch={Stitch:F1}ms render={Render:F1}ms total={Total:F1}ms",
captureMs, stitchMs, renderMs, sw.Elapsed.TotalMilliseconds);
return result;
}
public void SaveDebugCapture() => _capture.SaveDebugCapture();
public void Dispose()
{
_capture.Dispose();
_worldMap.Dispose();
}
}

View file

@ -0,0 +1,166 @@
using Poe2Trade.Core;
using OpenCvSharp;
namespace Poe2Trade.Navigation;
public enum MinimapMode
{
Overlay, // full-screen overlay (default, large)
Corner // tab minimap in top-right corner (small)
}
public enum NavigationState
{
Idle,
Capturing,
Processing,
Planning,
Moving,
Stuck,
Completed,
Failed
}
public record MapPosition(double X, double Y);
public enum MapCell : byte
{
Unknown = 0,
Explored = 1,
Wall = 2,
Fog = 3
}
public enum MinimapDebugStage
{
WorldMap,
Raw,
Walls,
Fog,
Player,
Classified,
Hue,
Saturation,
Value
}
public record MinimapFrame(
Mat GrayMat,
Mat WallMask,
Mat ClassifiedMat,
Point2d PlayerOffset,
long Timestamp,
List<Point>? CheckpointsOff = null,
List<Point>? CheckpointsOn = null
) : IDisposable
{
public void Dispose()
{
GrayMat.Dispose();
WallMask.Dispose();
ClassifiedMat.Dispose();
}
}
public class MinimapConfig
{
// Overlay minimap center on screen (2560x1440) — full-screen overlay
public int OverlayCenterX { get; set; } = 1280;
public int OverlayCenterY { get; set; } = 700;
// Corner minimap (tab minimap, top-right)
public int CornerCenterX { get; set; } = 2370;
public int CornerCenterY { get; set; } = 189;
public int CornerRadius { get; set; } = 180; // extends 180px in each direction
// Active capture size (pipeline operates at this resolution)
public int CaptureSize { get; set; } = 400;
// Capture region for overlay mode
public Region OverlayRegion => new(
OverlayCenterX - CaptureSize / 2,
OverlayCenterY - CaptureSize / 2,
CaptureSize, CaptureSize);
// Capture region for corner mode
public Region CornerRegion => new(
CornerCenterX - CornerRadius,
CornerCenterY - CornerRadius,
CornerRadius * 2, CornerRadius * 2);
// Scale factor: corner pixels → overlay pixels (tune until maps align across modes)
// Default assumes both show the same game area: overlay is 400px, corner is 360px
public double CornerScale { get; set; } = 1.0;
// Legacy alias used by click-to-move
public int MinimapCenterX => OverlayCenterX;
public int MinimapCenterY => OverlayCenterY;
// HSV range for player marker (orange X)
public Scalar PlayerLoHSV { get; set; } = new(5, 80, 80);
public Scalar PlayerHiHSV { get; set; } = new(25, 255, 255);
// Wall detection (overlay): target #A2AEE5 (blue-lavender structure lines)
// HSV(115, 75, 229) — blue hue, low-medium saturation, bright
public Scalar WallLoHSV { get; set; } = new(110, 25, 190);
public Scalar WallHiHSV { get; set; } = new(136, 120, 255);
// Wall detection (corner): wider V range for faded walls
public Scalar CornerWallLoHSV { get; set; } = new(110, 25, 100);
public Scalar CornerWallHiHSV { get; set; } = new(136, 120, 255);
// Connected components: minimum area to keep (kills speckle)
public int WallMinArea { get; set; } = 30;
// Fog of war detection: broad blue range, fog = allBlue minus walls
public Scalar FogLoHSV { get; set; } = new(85, 10, 130);
public Scalar FogHiHSV { get; set; } = new(140, 255, 255);
public int FogMinArea { get; set; } = 100;
// Explored radius: pixels around player position to mark as explored on world map
public int ExploredRadius { get; set; } = 75;
// Temporal smoothing: majority vote over ring buffer (walls only)
public int TemporalFrameCount { get; set; } = 5;
public int WallTemporalThreshold { get; set; } = 3;
// Capture rate (~30 fps)
public int CaptureIntervalMs { get; set; } = 33;
// Movement
public int ClickRadius { get; set; } = 100;
public int MovementWaitMs { get; set; } = 600;
// World map canvas
public int CanvasSize { get; set; } = 2000;
// Template matching: search radius around current position estimate (pixels)
public int MatchSearchRadius { get; set; } = 100;
// Template matching: minimum correlation confidence to accept a match
public double MatchConfidence { get; set; } = 0.25;
// Wall confidence (canvas-level): per-pixel counters to filter transient noise
// Walls need ceil(Threshold/Inc) = 3 frames of reinforcement to appear.
// Block noise filter handles glow; confidence just smooths frame-to-frame jitter.
public int ConfidenceInc { get; set; } = 5;
public int ConfidenceDec { get; set; } = 1;
public int ConfidenceThreshold { get; set; } = 13;
public int ConfidenceMax { get; set; } = 30;
public int WarmupFrames { get; set; } = 5;
// Frame dedup: min changed pixels to process a frame (skip near-identical minimap frames)
public int FrameChangeThreshold { get; set; } = 50;
// Noise gate: skip frames where wall pixels exceed this fraction of capture area
// Normal walls ~2-5%, waypoint glow / effects spike to 10%+
public double WallMaxDensity { get; set; } = 0.08;
// Stuck detection
public double StuckThreshold { get; set; } = 2.0;
public int StuckFrameCount { get; set; } = 5;
// Screen center for combat clicks (2560x1440 → center = 1280, 720)
public int ScreenCenterX { get; set; } = 1280;
public int ScreenCenterY { get; set; } = 720;
}

View file

@ -0,0 +1,420 @@
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
/// <summary>
/// Last BFS result for visualization.
/// </summary>
internal record BfsResult(
List<Point> Path, // canvas coords: player → target frontier (subsampled)
double DirX, double DirY, // chosen direction (unit vector)
int PlayerCx, int PlayerCy // player position on canvas
);
/// <summary>
/// BFS pathfinding through the world map canvas. Pure function — reads canvas, never modifies it.
/// </summary>
internal class PathFinder
{
/// <summary>Last BFS result for viewport overlay.</summary>
public BfsResult? LastResult { get; private set; }
/// <summary>
/// BFS through walkable (Explored) cells to find the best frontier cluster.
/// Runs full BFS, collects frontier cells (explored cells bordering unknown),
/// groups them into connected components, then picks the cluster with the
/// highest score = gain / (cost + 1) where gain = cluster size and cost = BFS
/// distance to the nearest cell in the cluster.
/// </summary>
public (double dirX, double dirY)? FindNearestUnexplored(Mat canvas, int canvasSize, 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 = canvasSize;
var rr = searchRadius / step;
var gridW = 2 * rr + 1;
var gridLen = gridW * gridW;
var visited = new bool[gridLen];
var cost = new int[gridLen];
var parentX = new short[gridLen];
var parentY = new short[gridLen];
// 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];
// Precompute wall proximity: count of 8 canvas-level neighbors that are Wall
var wallNear = new byte[gridLen];
for (var gy = 0; gy < gridW; gy++)
{
for (var gx = 0; gx < gridW; gx++)
{
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
if (wx < 1 || wx >= size - 1 || wy < 1 || wy >= size - 1) continue;
byte count = 0;
for (var d = 0; d < 8; d++)
{
if (canvas.At<byte>(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall)
count++;
}
wallNear[gy * gridW + gx] = count;
}
}
// Dijkstra setup
Array.Fill(cost, int.MaxValue);
var startGx = rr;
var startGy = rr;
var startIdx = startGy * gridW + startGx;
cost[startIdx] = 0;
parentX[startIdx] = (short)startGx;
parentY[startIdx] = (short)startGy;
var pq = new PriorityQueue<(int gx, int gy), int>(4096);
pq.Enqueue((startGx, startGy), 0);
// Step A: Dijkstra flood-fill with wall-proximity cost
var isFrontier = new bool[gridLen];
var frontierCells = new List<(int gx, int gy)>();
while (pq.Count > 0)
{
var (gx, gy) = pq.Dequeue();
var cellIdx = gy * gridW + gx;
if (visited[cellIdx]) continue;
visited[cellIdx] = true;
// Map grid coords back to canvas coords
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
// Step B: Check if this explored cell borders Unknown/Fog (= frontier)
if (gx != startGx || gy != startGy)
{
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)
{
isFrontier[cellIdx] = true;
frontierCells.Add((gx, gy));
break;
}
}
}
// 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 && cell != (byte)MapCell.Fog) continue;
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
if (newCost < cost[idx])
{
cost[idx] = newCost;
parentX[idx] = (short)gx;
parentY[idx] = (short)gy;
pq.Enqueue((ngx, ngy), newCost);
}
}
}
if (frontierCells.Count == 0)
{
Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius);
LastResult = null;
return null;
}
// Step C: Cluster frontiers via 8-connected flood-fill
var clusterVisited = new bool[gridLen];
var clusterCount = 0;
var bestClusterGain = 0;
var bestClusterCost = 0;
var bestClusterScore = -1.0;
var bestEntryGx = -1;
var bestEntryGy = -1;
var clusterQueue = new Queue<(int gx, int gy)>(256);
foreach (var (fgx, fgy) in frontierCells)
{
var fIdx = fgy * gridW + fgx;
if (clusterVisited[fIdx]) continue;
clusterCount++;
// Flood-fill this cluster
var clusterCells = new List<(int gx, int gy)>();
var minCost = cost[fIdx];
var entryGx = fgx;
var entryGy = fgy;
clusterVisited[fIdx] = true;
clusterQueue.Enqueue((fgx, fgy));
while (clusterQueue.Count > 0)
{
var (cgx, cgy) = clusterQueue.Dequeue();
clusterCells.Add((cgx, cgy));
var cIdx = cgy * gridW + cgx;
if (cost[cIdx] < minCost)
{
minCost = cost[cIdx];
entryGx = cgx;
entryGy = cgy;
}
for (var d = 0; d < 8; d++)
{
var ngx = cgx + dxs[d];
var ngy = cgy + dys[d];
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
var nIdx = ngy * gridW + ngx;
if (clusterVisited[nIdx] || !isFrontier[nIdx]) continue;
clusterVisited[nIdx] = true;
clusterQueue.Enqueue((ngx, ngy));
}
}
// Step D: Score this cluster — skip tiny nooks
const int MinClusterSize = 8;
var gain = clusterCells.Count;
if (gain < MinClusterSize) continue;
var pathCost = (int)minCost;
var score = gain / (pathCost + 1.0);
if (score > bestClusterScore)
{
bestClusterScore = score;
bestClusterGain = gain;
bestClusterCost = pathCost;
bestEntryGx = entryGx;
bestEntryGy = entryGy;
}
}
if (bestEntryGx < 0)
{
Log.Information("BFS: all {Count} frontier clusters too small (< 8 cells)", clusterCount);
LastResult = null;
return null;
}
// Step E: Trace path from entry cell of winning cluster back to start
var rawPath = new List<Point>();
var traceGx = bestEntryGx;
var traceGy = bestEntryGy;
while (traceGx != startGx || traceGy != startGy)
{
var wx = cx + (traceGx - rr) * step;
var wy = cy + (traceGy - rr) * step;
rawPath.Add(new Point(wx, wy));
var tIdx = traceGy * gridW + traceGx;
var pgx = parentX[tIdx];
var pgy = parentY[tIdx];
traceGx = pgx;
traceGy = pgy;
}
rawPath.Add(new Point(cx, cy));
rawPath.Reverse(); // player → frontier
// Subsample: every 4th point (~8px apart on canvas with step=2)
var path = new List<Point>();
for (var i = 0; i < rawPath.Count; i += 4)
path.Add(rawPath[i]);
if (path.Count == 0 || path[^1] != rawPath[^1])
path.Add(rawPath[^1]); // always include endpoint
// Direction from start toward first path segment
var dirX = (double)(path[Math.Min(1, path.Count - 1)].X - cx);
var dirY = (double)(path[Math.Min(1, path.Count - 1)].Y - cy);
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
if (len < 0.001) { dirX = 1; dirY = 0; len = 1; }
dirX /= len;
dirY /= len;
// Step F: Store result for visualization
LastResult = new BfsResult(path, dirX, dirY, cx, cy);
Log.Debug("BFS: {ClusterCount} clusters, best={Gain} cells cost={Cost} score={Score:F1}, dir=({Dx:F2},{Dy:F2}), path={PathLen} waypoints",
clusterCount, bestClusterGain, bestClusterCost, bestClusterScore, dirX, dirY, path.Count);
return (dirX, dirY);
}
/// <summary>
/// BFS to a specific target point (e.g. checkpoint). Same half-res grid as FindNearestUnexplored.
/// Terminates when a cell within ~10px of the target is reached.
/// Returns direction toward the target, or null if unreachable.
/// </summary>
public (double dirX, double dirY)? FindPathToTarget(Mat canvas, int canvasSize, MapPosition pos, Point target, int searchRadius = 400)
{
var cx = (int)Math.Round(pos.X);
var cy = (int)Math.Round(pos.Y);
const int step = 2;
var rr = searchRadius / step;
var gridW = 2 * rr + 1;
var gridLen = gridW * gridW;
var visited = new bool[gridLen];
var cost = new int[gridLen];
var parentX = new short[gridLen];
var parentY = new short[gridLen];
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
// Precompute wall proximity: count of 8 canvas-level neighbors that are Wall
var wallNear = new byte[gridLen];
for (var gy = 0; gy < gridW; gy++)
{
for (var gx = 0; gx < gridW; gx++)
{
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
if (wx < 1 || wx >= canvasSize - 1 || wy < 1 || wy >= canvasSize - 1) continue;
byte count = 0;
for (var d = 0; d < 8; d++)
{
if (canvas.At<byte>(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall)
count++;
}
wallNear[gy * gridW + gx] = count;
}
}
// Dijkstra setup
Array.Fill(cost, int.MaxValue);
var startGx = rr;
var startGy = rr;
var startIdx = startGy * gridW + startGx;
cost[startIdx] = 0;
parentX[startIdx] = (short)startGx;
parentY[startIdx] = (short)startGy;
var pq = new PriorityQueue<(int gx, int gy), int>(4096);
pq.Enqueue((startGx, startGy), 0);
const int arrivalDist = 10;
const int arrivalDist2 = arrivalDist * arrivalDist;
var foundGx = -1;
var foundGy = -1;
while (pq.Count > 0)
{
var (gx, gy) = pq.Dequeue();
var cellIdx = gy * gridW + gx;
if (visited[cellIdx]) continue;
visited[cellIdx] = true;
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
// Check if we've reached the target
var dtx = wx - target.X;
var dty = wy - target.Y;
if (dtx * dtx + dty * dty <= arrivalDist2)
{
foundGx = gx;
foundGy = gy;
break;
}
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 >= canvasSize || nwy < 0 || nwy >= canvasSize) continue;
var cell = canvas.At<byte>(nwy, nwx);
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
if (newCost < cost[idx])
{
cost[idx] = newCost;
parentX[idx] = (short)gx;
parentY[idx] = (short)gy;
pq.Enqueue((ngx, ngy), newCost);
}
}
}
if (foundGx < 0)
{
Log.Debug("BFS target: unreachable target=({TX},{TY}) from ({CX},{CY})", target.X, target.Y, cx, cy);
return null;
}
// Trace path back to start
var rawPath = new List<Point>();
var traceGx = foundGx;
var traceGy = foundGy;
while (traceGx != startGx || traceGy != startGy)
{
var wx = cx + (traceGx - rr) * step;
var wy = cy + (traceGy - rr) * step;
rawPath.Add(new Point(wx, wy));
var tIdx = traceGy * gridW + traceGx;
var pgx = parentX[tIdx];
var pgy = parentY[tIdx];
traceGx = pgx;
traceGy = pgy;
}
rawPath.Add(new Point(cx, cy));
rawPath.Reverse();
// Subsample every 4th point
var path = new List<Point>();
for (var i = 0; i < rawPath.Count; i += 4)
path.Add(rawPath[i]);
if (path.Count == 0 || path[^1] != rawPath[^1])
path.Add(rawPath[^1]);
// Direction from start toward first path segment
var dirX = (double)(path[Math.Min(1, path.Count - 1)].X - cx);
var dirY = (double)(path[Math.Min(1, path.Count - 1)].Y - cy);
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
if (len < 0.001) { dirX = 1; dirY = 0; len = 1; }
dirX /= len;
dirY /= len;
// Store result for visualization
LastResult = new BfsResult(path, dirX, dirY, cx, cy);
Log.Debug("BFS target: path={PathLen} waypoints to ({TX},{TY}), dir=({Dx:F2},{Dy:F2})",
path.Count, target.X, target.Y, dirX, dirY);
return (dirX, dirY);
}
}

View file

@ -0,0 +1,264 @@
using OpenCvSharp;
using Poe2Trade.Core;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Navigation;
public record CalibrationResult(float BestFactor, double BestConfidence, Dictionary<float, double> AllResults);
/// <summary>
/// Collects atlas frames and tests different perspective factors to find the optimal one.
/// For each candidate factor, stitches all frames into a mini-canvas and measures seam
/// quality (pixel difference in overlap regions). The correct factor minimizes seam error
/// because frames from different scroll directions align properly.
/// </summary>
public class PerspectiveCalibrator : IFrameConsumer, IDisposable
{
private readonly List<Mat> _frames = new();
private Mat? _lastKept;
private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
private const int MaxFrames = 100;
private const double MovementThreshold = 4.0;
public int FramesCollected => _frames.Count;
public event Action<int>? FrameCollected;
public void Process(ScreenFrame frame)
{
if (_frames.Count >= MaxFrames) return;
using var bgr = frame.CropBgr(CaptureRegion);
// Movement check at 1/8 scale
using var small = new Mat();
Cv2.Resize(bgr, small, new Size(bgr.Width / 8, bgr.Height / 8));
if (_lastKept != null)
{
using var diff = new Mat();
Cv2.Absdiff(small, _lastKept, diff);
var mean = Cv2.Mean(diff);
var avgDiff = (mean.Val0 + mean.Val1 + mean.Val2) / 3.0;
if (avgDiff < MovementThreshold)
return;
}
_lastKept?.Dispose();
_lastKept = small.Clone();
_frames.Add(bgr.Clone());
FrameCollected?.Invoke(_frames.Count);
Log.Debug("PerspectiveCalibrator: kept frame {N}/{Max}", _frames.Count, MaxFrames);
}
public CalibrationResult Calibrate()
{
if (_frames.Count < 3)
throw new InvalidOperationException($"Need at least 3 frames, got {_frames.Count}");
Log.Information("PerspectiveCalibrator: analyzing {N} frames with seam-error metric", _frames.Count);
var results = new Dictionary<float, double>();
// Coarse pass: 0.00 to 0.25, step 0.01
for (int fi = 0; fi <= 25; fi++)
{
var f = fi * 0.01f;
var quality = MeasureFactorQuality(f);
results[f] = quality;
Log.Information("Calibrate: factor={F:F2} seamQuality={Q:F6}", f, quality);
}
// Fine pass around the best coarse value
var coarseBest = results.MaxBy(kv => kv.Value).Key;
for (int fi = -9; fi <= 9; fi += 2)
{
var f = MathF.Round(coarseBest + fi * 0.001f, 3);
if (f < 0 || f > 0.30f || results.ContainsKey(f)) continue;
var quality = MeasureFactorQuality(f);
results[f] = quality;
Log.Information("Calibrate (fine): factor={F:F3} seamQuality={Q:F6}", f, quality);
}
var best = results.MaxBy(kv => kv.Value);
Log.Information("PerspectiveCalibrator: BEST factor={F:F3} seamQuality={Q:F6}", best.Key, best.Value);
return new CalibrationResult(best.Key, best.Value, results);
}
/// <summary>
/// Stitches all frames into a mini-canvas using the given perspective factor,
/// and measures the average seam quality (pixel alignment in overlap regions).
/// When the factor is correct, overlapping regions from different scroll directions
/// align perfectly → low pixel difference → high quality score.
/// </summary>
private double MeasureFactorQuality(float factor)
{
const int scale = 4;
var w = _frames[0].Width;
var h = _frames[0].Height;
var sw = w / scale;
var sh = h / scale;
// Compute warp matrix at reduced resolution
Mat? warpMatrix = null;
if (factor > 0.001f)
{
var inset = (int)(sw * factor);
var src = new Point2f[]
{
new(inset, 0), new(sw - inset, 0),
new(sw, sh), new(0, sh),
};
var dst = new Point2f[]
{
new(0, 0), new(sw, 0),
new(sw, sh), new(0, sh),
};
warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
}
var smallFrames = new List<Mat>();
try
{
// Downscale first, then warp
foreach (var frame in _frames)
{
var small = new Mat();
Cv2.Resize(frame, small, new Size(sw, sh));
if (warpMatrix != null)
{
var warped = new Mat();
Cv2.WarpPerspective(small, warped, warpMatrix, new Size(sw, sh));
small.Dispose();
smallFrames.Add(warped);
}
else
{
smallFrames.Add(small);
}
}
// Build mini-canvas and measure seam quality
const int canvasSize = 4000;
using var canvas = new Mat(canvasSize, canvasSize, MatType.CV_8UC3, Scalar.Black);
var vx = canvasSize / 2;
var vy = canvasSize / 2;
// Paste first frame
PasteMini(canvas, canvasSize, smallFrames[0], vx - sw / 2, vy - sh / 2);
const int templateW = 50;
const int templateH = 50;
const int searchMargin = 80; // at 1/4 scale ≈ 320px full res
var seamQualities = new List<double>();
for (int i = 1; i < smallFrames.Count; i++)
{
var frame = smallFrames[i];
// Find position via center template match against canvas
var tx = (sw - templateW) / 2;
var ty = (sh - templateH) / 2;
var halfW = sw / 2 + searchMargin;
var halfH = sh / 2 + searchMargin;
var sx0 = Math.Max(0, vx - halfW);
var sy0 = Math.Max(0, vy - halfH);
var sx1 = Math.Min(canvasSize, vx + halfW);
var sy1 = Math.Min(canvasSize, vy + halfH);
var sW = sx1 - sx0;
var sH = sy1 - sy0;
if (sW <= templateW || sH <= templateH) continue;
using var tmpl = new Mat(frame, new Rect(tx, ty, templateW, templateH));
using var searchRoi = new Mat(canvas, new Rect(sx0, sy0, sW, sH));
using var result = new Mat();
Cv2.MatchTemplate(searchRoi, tmpl, result, TemplateMatchModes.CCoeffNormed);
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal < 0.5) continue;
var frameX = sx0 + maxLoc.X - tx;
var frameY = sy0 + maxLoc.Y - ty;
var newVx = frameX + sw / 2;
var newVy = frameY + sh / 2;
// Skip near-static frames
if (Math.Abs(newVx - vx) < 5 && Math.Abs(newVy - vy) < 5) continue;
// Measure seam quality: pixel difference in overlap with existing canvas
var overlapX0 = Math.Max(frameX, 0);
var overlapY0 = Math.Max(frameY, 0);
var overlapX1 = Math.Min(frameX + sw, canvasSize);
var overlapY1 = Math.Min(frameY + sh, canvasSize);
var overlapW = overlapX1 - overlapX0;
var overlapH = overlapY1 - overlapY0;
if (overlapW > 20 && overlapH > 20)
{
var fsx = overlapX0 - frameX;
var fsy = overlapY0 - frameY;
using var canvasOverlap = new Mat(canvas, new Rect(overlapX0, overlapY0, overlapW, overlapH));
using var frameOverlap = new Mat(frame, new Rect(fsx, fsy, overlapW, overlapH));
// Only measure where canvas already has content (non-black)
using var gray = new Mat();
Cv2.CvtColor(canvasOverlap, gray, ColorConversionCodes.BGR2GRAY);
using var mask = new Mat();
Cv2.Threshold(gray, mask, 5, 255, ThresholdTypes.Binary);
var nonZero = Cv2.CountNonZero(mask);
if (nonZero > 500)
{
using var diff = new Mat();
Cv2.Absdiff(canvasOverlap, frameOverlap, diff);
var meanDiff = Cv2.Mean(diff, mask);
var avgDiff = (meanDiff.Val0 + meanDiff.Val1 + meanDiff.Val2) / 3.0;
seamQualities.Add(1.0 - avgDiff / 255.0);
}
}
// Paste frame onto canvas for future overlap comparisons
PasteMini(canvas, canvasSize, frame, frameX, frameY);
vx = newVx;
vy = newVy;
}
return seamQualities.Count > 0 ? seamQualities.Average() : 0;
}
finally
{
foreach (var s in smallFrames) s.Dispose();
warpMatrix?.Dispose();
}
}
private static void PasteMini(Mat canvas, int canvasSize, Mat frame, int canvasX, int canvasY)
{
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(frame.Width - srcX, canvasSize - dstX);
var h = Math.Min(frame.Height - srcY, canvasSize - dstY);
if (w <= 0 || h <= 0) return;
using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
using var dstRoi = new Mat(canvas, new Rect(dstX, dstY, w, h));
srcRoi.CopyTo(dstRoi);
}
public void Dispose()
{
foreach (var f in _frames) f.Dispose();
_frames.Clear();
_lastKept?.Dispose();
}
}

View file

@ -0,0 +1,19 @@
<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" />
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,40 @@
namespace Poe2Trade.Navigation;
/// <summary>
/// Detects when the player hasn't moved significantly over a window of frames.
/// </summary>
internal class StuckDetector
{
private readonly double _threshold;
private readonly int _frameCount;
private int _counter;
private MapPosition? _lastPosition;
public bool IsStuck => _counter >= _frameCount;
public StuckDetector(double threshold, int frameCount)
{
_threshold = threshold;
_frameCount = frameCount;
}
public void Update(MapPosition position)
{
if (_lastPosition != null)
{
var dx = position.X - _lastPosition.X;
var dy = position.Y - _lastPosition.Y;
if (Math.Sqrt(dx * dx + dy * dy) < _threshold)
_counter++;
else
_counter = 0;
}
_lastPosition = position;
}
public void Reset()
{
_counter = 0;
_lastPosition = null;
}
}

View file

@ -0,0 +1,155 @@
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
/// <summary>
/// Tracks HSV distribution of confirmed wall pixels and computes an adaptive
/// detection range that narrows per-map from the broad default.
/// </summary>
internal class WallColorTracker
{
private readonly Scalar _defaultLo;
private readonly Scalar _defaultHi;
// Cumulative histograms (committed samples only)
private readonly long[] _hHist = new long[180];
private readonly long[] _sHist = new long[256];
private readonly long[] _vHist = new long[256];
private long _totalSamples;
// Pending samples from the latest frame (not yet committed)
private readonly int[] _pendH = new int[180];
private readonly int[] _pendS = new int[256];
private readonly int[] _pendV = new int[256];
private int _pendCount;
// Generation counter: prevents committing samples from a previous mode after Reset()
private int _generation;
private int _pendGeneration;
private const int MinSamples = 3000;
private const double LoPercentile = 0.05;
private const double HiPercentile = 0.95;
private const int HPad = 5;
private const int SPad = 15;
private const int VPad = 10;
public Scalar? AdaptedLo { get; private set; }
public Scalar? AdaptedHi { get; private set; }
public WallColorTracker(Scalar defaultLo, Scalar defaultHi)
{
_defaultLo = defaultLo;
_defaultHi = defaultHi;
}
/// <summary>
/// Sample wall pixel HSV values from the current frame into pending buffers.
/// Must be called with the pre-dilation wall mask (only pure color-selected pixels).
/// Does NOT commit — call <see cref="Commit"/> only after WorldMap confirms the match.
/// </summary>
public void SampleFrame(Mat hsv, Mat preDilationMask)
{
Array.Clear(_pendH);
Array.Clear(_pendS);
Array.Clear(_pendV);
_pendCount = 0;
_pendGeneration = _generation;
// Downsample: every 4th pixel in each direction (~10K samples for 400x400)
for (var r = 0; r < hsv.Rows; r += 4)
for (var c = 0; c < hsv.Cols; c += 4)
{
if (preDilationMask.At<byte>(r, c) == 0) continue;
var px = hsv.At<Vec3b>(r, c);
_pendH[px.Item0]++;
_pendS[px.Item1]++;
_pendV[px.Item2]++;
_pendCount++;
}
}
/// <summary>
/// Commit pending samples into the cumulative histogram.
/// Call only after WorldMap confirms a successful template match.
/// </summary>
public void Commit()
{
if (_pendCount == 0) return;
if (_pendGeneration != _generation) return; // stale cross-mode samples
for (var i = 0; i < 180; i++) _hHist[i] += _pendH[i];
for (var i = 0; i < 256; i++) _sHist[i] += _pendS[i];
for (var i = 0; i < 256; i++) _vHist[i] += _pendV[i];
_totalSamples += _pendCount;
if (_totalSamples >= MinSamples)
Recompute();
}
private void Recompute()
{
var hLo = Percentile(_hHist, 180, LoPercentile);
var hHi = Percentile(_hHist, 180, HiPercentile);
var sLo = Percentile(_sHist, 256, LoPercentile);
var sHi = Percentile(_sHist, 256, HiPercentile);
var vLo = Percentile(_vHist, 256, LoPercentile);
var vHi = Percentile(_vHist, 256, HiPercentile);
// Clamp to default bounds — adaptation can only narrow, never broaden
var newLo = new Scalar(
Math.Max(_defaultLo.Val0, hLo - HPad),
Math.Max(_defaultLo.Val1, sLo - SPad),
Math.Max(_defaultLo.Val2, vLo - VPad));
var newHi = new Scalar(
Math.Min(_defaultHi.Val0, hHi + HPad),
Math.Min(_defaultHi.Val1, sHi + SPad),
Math.Min(_defaultHi.Val2, vHi + VPad));
// Safety: if any channel inverted (lo > hi), fall back to default for that channel
if (newLo.Val0 > newHi.Val0) { newLo.Val0 = _defaultLo.Val0; newHi.Val0 = _defaultHi.Val0; }
if (newLo.Val1 > newHi.Val1) { newLo.Val1 = _defaultLo.Val1; newHi.Val1 = _defaultHi.Val1; }
if (newLo.Val2 > newHi.Val2) { newLo.Val2 = _defaultLo.Val2; newHi.Val2 = _defaultHi.Val2; }
// Only log when the range actually changes
if (AdaptedLo == null || AdaptedHi == null ||
AdaptedLo.Value.Val0 != newLo.Val0 || AdaptedLo.Value.Val1 != newLo.Val1 ||
AdaptedHi.Value.Val0 != newHi.Val0 || AdaptedHi.Value.Val1 != newHi.Val1)
{
Log.Information(
"Wall color adapted: H({HLo}-{HHi}) S({SLo}-{SHi}) V({VLo}-{VHi}) from {Samples} samples",
newLo.Val0, newHi.Val0, newLo.Val1, newHi.Val1, newLo.Val2, newHi.Val2, _totalSamples);
}
AdaptedLo = newLo;
AdaptedHi = newHi;
}
private int Percentile(long[] hist, int size, double p)
{
var target = (long)(_totalSamples * p);
long cum = 0;
for (var i = 0; i < size; i++)
{
cum += hist[i];
if (cum >= target) return i;
}
return size - 1;
}
public void Reset()
{
Array.Clear(_hHist);
Array.Clear(_sHist);
Array.Clear(_vHist);
_totalSamples = 0;
Array.Clear(_pendH);
Array.Clear(_pendS);
Array.Clear(_pendV);
_pendCount = 0;
AdaptedLo = null;
AdaptedHi = null;
_generation++;
}
}

View file

@ -0,0 +1,734 @@
using System.Diagnostics;
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
public class WorldMap : IDisposable
{
private readonly MinimapConfig _config;
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();
// Checkpoint tracking (canvas coordinates)
private readonly List<(Point Pos, long LastSeenMs)> _checkpointsOff = [];
private readonly List<(Point Pos, long LastSeenMs)> _checkpointsOn = [];
private const int CheckpointDedupRadius = 20;
// World origin: cumulative offset from canvas (0,0) to world (0,0).
// World coords = canvas coords - _worldOrigin. Stable across canvas growth.
private double _worldOriginX;
private double _worldOriginY;
public MapPosition Position => _position;
/// <summary>
/// Player position in stable world coordinates (invariant to canvas growth).
/// World (0,0) = where the player spawned.
/// </summary>
public MapPosition WorldPosition => new(_position.X - _worldOriginX, _position.Y - _worldOriginY);
public bool LastMatchSucceeded { get; private set; }
public bool Frozen { get; set; }
public int CanvasSize => _canvasSize;
internal List<Point>? LastBfsPath => _pathFinder.LastResult?.Path;
private const int GrowMargin = 500;
private const int GrowAmount = 2000; // 1000px added per side
public WorldMap(MinimapConfig config)
{
_config = config;
_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);
// World origin = initial player position, so WorldPosition starts at (0,0)
_worldOriginX = _position.X;
_worldOriginY = _position.Y;
}
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);
_worldOriginX += offset;
_worldOriginY += offset;
// Shift checkpoint canvas coordinates
for (var i = 0; i < _checkpointsOff.Count; i++)
_checkpointsOff[i] = (new Point(_checkpointsOff[i].Pos.X + offset, _checkpointsOff[i].Pos.Y + offset), _checkpointsOff[i].LastSeenMs);
for (var i = 0; i < _checkpointsOn.Count; i++)
_checkpointsOn[i] = (new Point(_checkpointsOn[i].Pos.X + offset, _checkpointsOn[i].Pos.Y + offset), _checkpointsOn[i].LastSeenMs);
Log.Information("Canvas grown: {Old}→{New}, offset={Offset}, worldOrigin=({Ox:F0},{Oy:F0})",
oldSize, newSize, offset, _worldOriginX, _worldOriginY);
}
/// <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, MinimapMode mode = MinimapMode.Overlay,
List<Point>? checkpointsOff = null, List<Point>? checkpointsOn = null)
{
EnsureCapacity();
var sw = Stopwatch.StartNew();
_frameCount++;
var isCorner = mode == MinimapMode.Corner;
var warmupFrames = isCorner ? 2 : _config.WarmupFrames;
var needsBootstrap = _frameCount <= warmupFrames || _consecutiveMatchFails >= 30;
Log.Debug("MatchAndStitch: frame#{N} mode={Mode} frameSize={W}x{H} pos=({X:F1},{Y:F1}) bootstrap={Boot} prevWallMask={HasPrev}",
_frameCount, mode, wallMask.Width, wallMask.Height,
_position.X, _position.Y, needsBootstrap, _prevWallMask != null);
var wallCountBefore = Cv2.CountNonZero(wallMask);
if (ShouldSkipFrame(classifiedMat, wallMask, isCorner, needsBootstrap, sw))
return _position;
var wallCountAfter = Cv2.CountNonZero(wallMask);
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)
{
// Don't consume warmup slots on empty frames (game still loading minimap)
if (wallCountAfter < 50)
{
_frameCount--;
Log.Debug("Warmup waiting for minimap ({Ms:F1}ms)", sw.Elapsed.TotalMilliseconds);
return _position;
}
StitchWithConfidence(classifiedMat, _position, boosted: true, mode: mode);
PaintExploredCircle(_position);
MergeCheckpoints(_position, classifiedMat.Width, checkpointsOff, checkpointsOn);
LastMatchSucceeded = true; // signal caller to update viewport
if (_consecutiveMatchFails >= 30)
{
Log.Information("Re-bootstrap: mode={Mode} pos=({X:F1},{Y:F1}) frameSize={FS} walls={W} stitch={Ms:F1}ms",
mode, _position.X, _position.Y, classifiedMat.Width, wallCountAfter, sw.Elapsed.TotalMilliseconds);
_consecutiveMatchFails = 0;
}
else
{
Log.Debug("Warmup frame {N}/{Total}: walls={WallsBefore}→{WallsAfter}(filtered) frameSize={FS} stitch={Ms:F1}ms",
_frameCount, _config.WarmupFrames, wallCountBefore, wallCountAfter,
wallMask.Width, 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.Debug("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;
var prevPos = _position;
_position = matched;
var stitchStart = sw.Elapsed.TotalMilliseconds;
if (!Frozen)
{
StitchWithConfidence(classifiedMat, _position, boosted: false, mode: mode);
PaintExploredCircle(_position);
MergeCheckpoints(_position, classifiedMat.Width, checkpointsOff, checkpointsOn);
}
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
var posDx = _position.X - prevPos.X;
var posDy = _position.Y - prevPos.Y;
Log.Debug("MatchAndStitch: mode={Mode} pos=({X:F1},{Y:F1}) moved=({Dx:F1},{Dy:F1}) dedup={Dedup:F1}ms match={Match:F1}ms stitch={Stitch:F1}ms total={Total:F1}ms",
mode, _position.X, _position.Y, posDx, posDy, dedupMs, matchMs, stitchMs, sw.Elapsed.TotalMilliseconds);
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.Debug("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;
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(_canvasSize, sx + searchSize);
var sy1 = Math.Min(_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 (frameWallCount < 50)
{
Log.Debug("Match fail: too few frame walls ({FrameWalls}) frameSize={FS}",
frameWallCount, frameSize);
return null;
}
if (canvasWallCount < 50)
{
Log.Debug("Match fail: too few canvas walls ({CanvasWalls}) frame walls={FrameWalls} frameSize={FS} searchROI={SW}x{SH}",
canvasWallCount, frameWallCount, frameSize, sw, sh);
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.Debug("Match fail: low confidence {Conf:F3} (need {Min:F2}) frameSize={FS} searchROI={SW}x{SH} canvas={CanvasWalls} frame={FrameWalls}",
maxVal, _config.MatchConfidence, frameSize, sw, sh, 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;
var deltaX = matchX - estimate.X;
var deltaY = matchY - estimate.Y;
Log.Debug("Match OK: conf={Conf:F3} pos=({X:F1},{Y:F1}) delta=({Dx:F1},{Dy:F1}) frameSize={FS} searchROI={SW}x{SH} canvas={CanvasWalls} frame={FrameWalls}",
maxVal, matchX, matchY, deltaX, deltaY, frameSize, sw, sh, canvasWallCount, frameWallCount);
return new MapPosition(matchX, matchY);
}
private void StitchWithConfidence(Mat classifiedMat, MapPosition position, bool boosted,
MinimapMode mode = MinimapMode.Overlay)
{
var isCorner = mode == MinimapMode.Corner;
var frameSize = classifiedMat.Width;
var halfSize = frameSize / 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(frameSize - srcX, _canvasSize - dstX);
var h = Math.Min(frameSize - srcY, _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);
// Corner minimap is clean — trust walls immediately, lower threshold
var confInc = isCorner ? (short)_config.ConfidenceMax : (short)_config.ConfidenceInc;
var confDec = (short)_config.ConfidenceDec;
var confThreshold = isCorner ? (short)2 : (short)_config.ConfidenceThreshold;
var confMax = (short)_config.ConfidenceMax;
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)
{
conf = Math.Max((short)(conf - confDec), (short)0);
}
else
{
continue;
}
confRoi.Set(row, col, conf);
var current = dstRoi.At<byte>(row, col);
// Corner mode: no double-evidence needed (clean data)
var needed = !isCorner && current == (byte)MapCell.Explored
? (short)(confThreshold * 2)
: confThreshold;
if (conf >= needed)
dstRoi.Set(row, col, (byte)MapCell.Wall);
else if (current == (byte)MapCell.Wall && conf < confThreshold)
dstRoi.Set(row, col, (byte)MapCell.Explored);
}
// Mark fog on canvas (only on Unknown cells — never overwrite Explored)
if (isCorner)
{
for (var row = 0; row < h; row++)
for (var col = 0; col < w; col++)
{
if (srcRoi.At<byte>(row, col) != (byte)MapCell.Fog) continue;
if (dstRoi.At<byte>(row, col) == (byte)MapCell.Unknown)
dstRoi.Set(row, col, (byte)MapCell.Fog);
}
}
else
{
// Overlay: exclude small area near player center (spell effect noise)
const int fogInner = 30;
const int fogInner2 = fogInner * fogInner;
for (var row = 0; row < h; row++)
for (var col = 0; col < w; col++)
{
if (srcRoi.At<byte>(row, col) != (byte)MapCell.Fog) continue;
var fx = srcX + col - halfSize;
var fy = srcY + row - halfSize;
if (fx * fx + fy * fy < fogInner2) continue;
if (dstRoi.At<byte>(row, col) == (byte)MapCell.Unknown)
dstRoi.Set(row, col, (byte)MapCell.Fog);
}
}
}
/// <summary>
/// Mark explored area: circle around player position, overwrite Unknown and Fog cells.
/// </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(_canvasSize - 1, pcy + r);
var x0 = Math.Max(0, pcx - r);
var x1 = Math.Min(_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;
var cell = _canvas.At<byte>(y, x);
if (cell == (byte)MapCell.Unknown || cell == (byte)MapCell.Fog)
_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 = 400)
=> _pathFinder.FindNearestUnexplored(_canvas, _canvasSize, pos, searchRadius);
public (double dirX, double dirY)? FindPathToTarget(MapPosition pos, Point target, int searchRadius = 400)
=> _pathFinder.FindPathToTarget(_canvas, _canvasSize, pos, target, searchRadius);
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, _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));
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));
else if (v == (byte)MapCell.Fog)
colored.Set(r, c, new Vec3b(120, 70, 40));
}
// BFS overlay: direction arrow + path line
var bfs = _pathFinder.LastResult;
if (bfs != null)
{
// Direction line from player
var px2 = bfs.PlayerCx - x0;
var py2 = bfs.PlayerCy - y0;
var lineLen = 60;
var ex = (int)(px2 + bfs.DirX * lineLen);
var ey = (int)(py2 + bfs.DirY * lineLen);
Cv2.ArrowedLine(colored, new Point(px2, py2), new Point(ex, ey),
new Scalar(0, 220, 0), 2, tipLength: 0.3);
// BFS path polyline (bright green)
if (bfs.Path.Count >= 2)
{
var viewPath = new Point[bfs.Path.Count];
for (var i = 0; i < bfs.Path.Count; i++)
viewPath[i] = new Point(bfs.Path[i].X - x0, bfs.Path[i].Y - y0);
Cv2.Polylines(colored, [viewPath], isClosed: false,
color: new Scalar(0, 255, 0), thickness: 1);
}
}
// Checkpoint markers
foreach (var (cp, _) in _checkpointsOff)
{
var cpx = cp.X - x0;
var cpy = cp.Y - y0;
if (cpx >= 0 && cpx < viewSize && cpy >= 0 && cpy < viewSize)
Cv2.Circle(colored, new Point(cpx, cpy), 5, new Scalar(100, 100, 100), -1); // gray
}
foreach (var (cp, _) in _checkpointsOn)
{
var cpx = cp.X - x0;
var cpy = cp.Y - y0;
if (cpx >= 0 && cpx < viewSize && cpy >= 0 && cpy < viewSize)
Cv2.Circle(colored, new Point(cpx, cpy), 5, new Scalar(220, 200, 0), -1); // bright cyan (BGR)
}
// Player dot (orange, on top)
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;
}
/// <summary>
/// Convert frame-relative checkpoint positions to canvas coords and merge with existing lists.
/// When a checkpoint-on appears near a checkpoint-off, remove the off entry.
/// </summary>
private void MergeCheckpoints(MapPosition position, int frameSize,
List<Point>? offPoints, List<Point>? onPoints)
{
var halfSize = frameSize / 2;
var canvasX = (int)Math.Round(position.X) - halfSize;
var canvasY = (int)Math.Round(position.Y) - halfSize;
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var r2 = CheckpointDedupRadius * CheckpointDedupRadius;
if (offPoints != null)
{
foreach (var fp in offPoints)
{
var cp = new Point(canvasX + fp.X, canvasY + fp.Y);
if (!IsDuplicate(_checkpointsOff, cp, r2))
_checkpointsOff.Add((cp, now));
}
}
if (onPoints != null)
{
foreach (var fp in onPoints)
{
var cp = new Point(canvasX + fp.X, canvasY + fp.Y);
// Remove matching off-checkpoint (it got activated)
for (var i = _checkpointsOff.Count - 1; i >= 0; i--)
{
var dx = _checkpointsOff[i].Pos.X - cp.X;
var dy = _checkpointsOff[i].Pos.Y - cp.Y;
if (dx * dx + dy * dy <= r2)
{
_checkpointsOff.RemoveAt(i);
break;
}
}
if (!IsDuplicate(_checkpointsOn, cp, r2))
_checkpointsOn.Add((cp, now));
}
}
}
private static bool IsDuplicate(List<(Point Pos, long LastSeenMs)> list, Point pt, int r2)
{
foreach (var (pos, _) in list)
{
var dx = pos.X - pt.X;
var dy = pos.Y - pt.Y;
if (dx * dx + dy * dy <= r2)
return true;
}
return false;
}
/// <summary>
/// Remove a checkpoint from the off list (e.g. when the player has reached it).
/// </summary>
public void RemoveCheckpointOff(Point cp, int radius = 20)
{
var r2 = radius * radius;
for (var i = _checkpointsOff.Count - 1; i >= 0; i--)
{
var dx = _checkpointsOff[i].Pos.X - cp.X;
var dy = _checkpointsOff[i].Pos.Y - cp.Y;
if (dx * dx + dy * dy <= r2)
{
_checkpointsOff.RemoveAt(i);
break;
}
}
}
/// <summary>
/// Returns the nearest unactivated checkpoint within maxDist canvas pixels, or null.
/// </summary>
public Point? GetNearestCheckpointOff(MapPosition pos, int maxDist = 200)
{
var px = (int)Math.Round(pos.X);
var py = (int)Math.Round(pos.Y);
var maxDist2 = maxDist * maxDist;
Point? best = null;
var bestDist2 = int.MaxValue;
foreach (var (cp, _) in _checkpointsOff)
{
var dx = cp.X - px;
var dy = cp.Y - py;
var d2 = dx * dx + dy * dy;
if (d2 <= maxDist2 && d2 < bestDist2)
{
bestDist2 = d2;
best = cp;
}
}
return best;
}
/// <summary>Convert world coordinates to canvas coordinates.</summary>
public MapPosition WorldToCanvas(double worldX, double worldY) =>
new(worldX + _worldOriginX, worldY + _worldOriginY);
/// <summary>Convert canvas coordinates to world coordinates.</summary>
public MapPosition CanvasToWorld(double canvasX, double canvasY) =>
new(canvasX - _worldOriginX, canvasY - _worldOriginY);
public void Reset()
{
_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(_canvasSize / 2.0, _canvasSize / 2.0);
_worldOriginX = _position.X;
_worldOriginY = _position.Y;
_frameCount = 0;
_consecutiveMatchFails = 0;
LastMatchSucceeded = false;
_checkpointsOff.Clear();
_checkpointsOn.Clear();
}
/// <summary>
/// Mode switch: halve confidence around current position so old mode's weak walls
/// decay quickly while strong walls provide matching reference for the new mode.
/// No re-bootstrap stitch — let the first match find the correct position.
/// </summary>
public void Rebootstrap()
{
Log.Information("Rebootstrap: frameCount={N} pos=({X:F1},{Y:F1}) matchFails={Fails} prevWallMask={Size}",
_frameCount, _position.X, _position.Y, _consecutiveMatchFails,
_prevWallMask != null ? $"{_prevWallMask.Width}x{_prevWallMask.Height}" : "null");
// Halve confidence around current position — weak walls get demoted,
// strong walls survive to help the new mode's first match find position
var halfClear = 250; // slightly larger than largest frame half (200)
var cx = (int)Math.Round(_position.X);
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(_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);
var confThreshold = (short)_config.ConfidenceThreshold;
using var confRoi = new Mat(_confidence, rect);
using var canvasRoi = new Mat(_canvas, rect);
var demoted = 0;
for (var row = 0; row < h; row++)
for (var col = 0; col < w; col++)
{
var conf = confRoi.At<short>(row, col);
if (conf <= 0) continue;
conf = (short)(conf / 2);
confRoi.Set(row, col, conf);
if (conf < confThreshold && canvasRoi.At<byte>(row, col) == (byte)MapCell.Wall)
{
canvasRoi.Set(row, col, (byte)MapCell.Explored);
demoted++;
}
}
Log.Information("Rebootstrap: halved confidence in {W}x{H} area, demoted {Demoted} weak walls", w, h, demoted);
}
_prevWallMask?.Dispose();
_prevWallMask = null;
_frameCount = 0; // force re-warmup with new mode's data
}
public void Dispose()
{
_canvas.Dispose();
_confidence.Dispose();
_prevWallMask?.Dispose();
}
}