rename
This commit is contained in:
parent
bef61f841d
commit
c3de5fdb63
107 changed files with 0 additions and 0 deletions
352
src/Automata.Navigation/AtlasPanorama.cs
Normal file
352
src/Automata.Navigation/AtlasPanorama.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
128
src/Automata.Navigation/IconDetector.cs
Normal file
128
src/Automata.Navigation/IconDetector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
446
src/Automata.Navigation/MinimapCapture.cs
Normal file
446
src/Automata.Navigation/MinimapCapture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
561
src/Automata.Navigation/NavigationExecutor.cs
Normal file
561
src/Automata.Navigation/NavigationExecutor.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
166
src/Automata.Navigation/NavigationTypes.cs
Normal file
166
src/Automata.Navigation/NavigationTypes.cs
Normal 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;
|
||||
}
|
||||
420
src/Automata.Navigation/PathFinder.cs
Normal file
420
src/Automata.Navigation/PathFinder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
264
src/Automata.Navigation/PerspectiveCalibrator.cs
Normal file
264
src/Automata.Navigation/PerspectiveCalibrator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
19
src/Automata.Navigation/Poe2Trade.Navigation.csproj
Normal file
19
src/Automata.Navigation/Poe2Trade.Navigation.csproj
Normal 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>
|
||||
40
src/Automata.Navigation/StuckDetector.cs
Normal file
40
src/Automata.Navigation/StuckDetector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
155
src/Automata.Navigation/WallColorTracker.cs
Normal file
155
src/Automata.Navigation/WallColorTracker.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
734
src/Automata.Navigation/WorldMap.cs
Normal file
734
src/Automata.Navigation/WorldMap.cs
Normal 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.0–1.0).
|
||||
/// </summary>
|
||||
private static double FilterNoisyBlocks(Mat wallMask, Mat classifiedMat,
|
||||
int blockSize = 50, double blockMaxDensity = 0.25)
|
||||
{
|
||||
var rows = wallMask.Rows;
|
||||
var cols = wallMask.Cols;
|
||||
var totalBlocks = 0;
|
||||
var cleanBlocks = 0;
|
||||
|
||||
for (var by = 0; by < rows; by += blockSize)
|
||||
for (var bx = 0; bx < cols; bx += blockSize)
|
||||
{
|
||||
var bw = Math.Min(blockSize, cols - bx);
|
||||
var bh = Math.Min(blockSize, rows - by);
|
||||
totalBlocks++;
|
||||
|
||||
var blockRect = new Rect(bx, by, bw, bh);
|
||||
using var blockRoi = new Mat(wallMask, blockRect);
|
||||
var wallCount = Cv2.CountNonZero(blockRoi);
|
||||
|
||||
if ((double)wallCount / (bw * bh) > blockMaxDensity)
|
||||
{
|
||||
// Zero out this noisy block in both mats
|
||||
blockRoi.SetTo(Scalar.Black);
|
||||
using var classBlock = new Mat(classifiedMat, blockRect);
|
||||
classBlock.SetTo(Scalar.Black);
|
||||
}
|
||||
else
|
||||
{
|
||||
cleanBlocks++;
|
||||
}
|
||||
}
|
||||
|
||||
return totalBlocks > 0 ? (double)cleanBlocks / totalBlocks : 1.0;
|
||||
}
|
||||
|
||||
public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue