poe2-bot/src/Poe2Trade.Navigation/WallColorTracker.cs
2026-02-16 13:18:04 -05:00

155 lines
5.4 KiB
C#

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++;
}
}