diff --git a/src/Poe2Trade.Navigation/MinimapCapture.cs b/src/Poe2Trade.Navigation/MinimapCapture.cs index f242d2d..07685f0 100644 --- a/src/Poe2Trade.Navigation/MinimapCapture.cs +++ b/src/Poe2Trade.Navigation/MinimapCapture.cs @@ -9,10 +9,12 @@ public class MinimapCapture : IDisposable { private readonly MinimapConfig _config; private readonly IScreenCapture _backend; + private readonly WallColorTracker _colorTracker; public MinimapCapture(MinimapConfig config) { _config = config; + _colorTracker = new WallColorTracker(config.WallLoHSV, config.WallHiHSV); _backend = CreateBackend(); } @@ -61,8 +63,8 @@ public class MinimapCapture : IDisposable Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask); var playerOffset = FindCentroid(playerMask); - // Wall mask: target #A2AEE5 blue-lavender structure lines - var wallMask = BuildWallMask(hsv, playerMask); + // Wall mask: target #A2AEE5 blue-lavender structure lines (range adapts per-map) + var wallMask = BuildWallMask(hsv, playerMask, sample: true); // Build classified mat (walls only — for stitching) var classified = new Mat(_config.CaptureSize, _config.CaptureSize, MatType.CV_8UC1, Scalar.Black); @@ -82,18 +84,24 @@ public class MinimapCapture : IDisposable ); } - private Mat BuildWallMask(Mat hsv, Mat playerMask) + private Mat BuildWallMask(Mat hsv, Mat playerMask, bool sample = false) { - // Target wall color #A2AEE5 — HSV(115, 75, 229) - // This is map-independent: walls are always blue-lavender, fog is higher-saturation blue + // Use adapted range if available (narrows per-map), otherwise broad default + var lo = _colorTracker.AdaptedLo ?? _config.WallLoHSV; + var hi = _colorTracker.AdaptedHi ?? _config.WallHiHSV; + var wallMask = new Mat(); - Cv2.InRange(hsv, _config.WallLoHSV, _config.WallHiHSV, wallMask); + Cv2.InRange(hsv, lo, hi, wallMask); // Subtract player marker (orange overlaps blue range slightly on some maps) using var notPlayer = new Mat(); Cv2.BitwiseNot(playerMask, notPlayer); Cv2.BitwiseAnd(wallMask, notPlayer, wallMask); + // Sample from pure color-selected pixels BEFORE morphological ops + if (sample) + _colorTracker.SampleFrame(hsv, wallMask); + // Dilate to connect thin wall line fragments using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)); Cv2.Dilate(wallMask, wallMask, kernel); @@ -218,6 +226,12 @@ public class MinimapCapture : IDisposable return new Point2d(cx, cy); } + /// Commit pending wall color samples (call after successful template match). + public void CommitWallColors() => _colorTracker.Commit(); + + /// Reset adaptive color tracking (call on area change). + public void ResetAdaptation() => _colorTracker.Reset(); + public void Dispose() { _backend.Dispose(); diff --git a/src/Poe2Trade.Navigation/NavigationExecutor.cs b/src/Poe2Trade.Navigation/NavigationExecutor.cs index 888a503..b6a54e1 100644 --- a/src/Poe2Trade.Navigation/NavigationExecutor.cs +++ b/src/Poe2Trade.Navigation/NavigationExecutor.cs @@ -45,6 +45,7 @@ public class NavigationExecutor : IDisposable public void Reset() { _worldMap.Reset(); + _capture.ResetAdaptation(); _stopped = false; _stuckCounter = 0; _lastPosition = null; @@ -81,6 +82,8 @@ public class NavigationExecutor : IDisposable SetState(NavigationState.Processing); var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask); + if (_worldMap.LastMatchSucceeded) + _capture.CommitWallColors(); // Stuck detection: position hasn't moved enough over several frames if (_lastPosition != null) @@ -186,6 +189,8 @@ public class NavigationExecutor : IDisposable var stitchStart = sw.Elapsed.TotalMilliseconds; var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask); + if (_worldMap.LastMatchSucceeded) + _capture.CommitWallColors(); var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart; var renderStart = sw.Elapsed.TotalMilliseconds; diff --git a/src/Poe2Trade.Navigation/WallColorTracker.cs b/src/Poe2Trade.Navigation/WallColorTracker.cs new file mode 100644 index 0000000..c39f409 --- /dev/null +++ b/src/Poe2Trade.Navigation/WallColorTracker.cs @@ -0,0 +1,148 @@ +using OpenCvSharp; +using Serilog; + +namespace Poe2Trade.Navigation; + +/// +/// Tracks HSV distribution of confirmed wall pixels and computes an adaptive +/// detection range that narrows per-map from the broad default. +/// +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; + + 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; + } + + /// + /// 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 only after WorldMap confirms the match. + /// + public void SampleFrame(Mat hsv, Mat preDilationMask) + { + Array.Clear(_pendH); + Array.Clear(_pendS); + Array.Clear(_pendV); + _pendCount = 0; + + // 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(r, c) == 0) continue; + var px = hsv.At(r, c); + _pendH[px.Item0]++; + _pendS[px.Item1]++; + _pendV[px.Item2]++; + _pendCount++; + } + } + + /// + /// Commit pending samples into the cumulative histogram. + /// Call only after WorldMap confirms a successful template match. + /// + public void Commit() + { + if (_pendCount == 0) return; + + 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; + } +} diff --git a/src/Poe2Trade.Navigation/WorldMap.cs b/src/Poe2Trade.Navigation/WorldMap.cs index 7656f73..f8d4252 100644 --- a/src/Poe2Trade.Navigation/WorldMap.cs +++ b/src/Poe2Trade.Navigation/WorldMap.cs @@ -15,6 +15,7 @@ public class WorldMap : IDisposable private Mat? _prevWallMask; // for frame deduplication public MapPosition Position => _position; + public bool LastMatchSucceeded { get; private set; } public WorldMap(MinimapConfig config) { @@ -94,12 +95,14 @@ public class WorldMap : IDisposable if (matched == null) { _consecutiveMatchFails++; + LastMatchSucceeded = false; Log.Information("MatchAndStitch: dedup={Dedup:F1}ms match={Match:F1}ms (FAILED x{Fails}) total={Total:F1}ms", dedupMs, matchMs, _consecutiveMatchFails, sw.Elapsed.TotalMilliseconds); return _position; // don't stitch — wrong position would corrupt the canvas } _consecutiveMatchFails = 0; + LastMatchSucceeded = true; _position = matched; var stitchStart = sw.Elapsed.TotalMilliseconds; StitchWithConfidence(classifiedMat, _position, boosted: false);