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);